From 42fc535896a8c0b733bcd63b551bad3cbd281fd7 Mon Sep 17 00:00:00 2001 From: Beno!t POLASZEK Date: Fri, 5 Feb 2021 10:37:13 +0100 Subject: [PATCH] refactor: move filter / map logic into IterableObject --- composer.json | 1 + src/IterableObject.php | 75 ++++------- src/MappedTraversable.php | 41 ++++++ src/iterable-functions.php | 33 ++--- tests/IterableFilterTest.php | 18 ++- tests/IterableMapTest.php | 11 +- tests/IterableObjectTest.php | 250 ++++++++++++----------------------- 7 files changed, 189 insertions(+), 240 deletions(-) create mode 100644 src/MappedTraversable.php diff --git a/composer.json b/composer.json index 7730355..b919f1a 100644 --- a/composer.json +++ b/composer.json @@ -21,6 +21,7 @@ "php": "^7.3 || ^8.0" }, "require-dev": { + "bentools/cartesian-product": "^1.3", "doctrine/coding-standard": "^8.2", "pestphp/pest": "^1.0", "phpstan/extension-installer": "^1.1", diff --git a/src/IterableObject.php b/src/IterableObject.php index 4901e86..59cc296 100644 --- a/src/IterableObject.php +++ b/src/IterableObject.php @@ -4,10 +4,15 @@ namespace BenTools\IterableFunctions; -use EmptyIterator; +use CallbackFilterIterator; use IteratorAggregate; +use IteratorIterator; use Traversable; +use function array_filter; +use function array_map; +use function iterator_to_array; + /** * @internal * @@ -18,42 +23,38 @@ final class IterableObject implements IteratorAggregate /** @var iterable */ private $iterable; - /** @var callable|null */ - private $filterFn; - - /** @var callable|null */ - private $mapFn; - - /** - * @param iterable|null $iterable - */ - private function __construct(?iterable $iterable = null, ?callable $filter = null, ?callable $map = null) - { - $this->iterable = $iterable ?? new EmptyIterator(); - $this->filterFn = $filter; - $this->mapFn = $map; - } - /** - * @param iterable|null $iterable + * @param iterable $iterable */ - public static function new(?iterable $iterable = null): self + public function __construct(iterable $iterable) { - return new self($iterable); + $this->iterable = $iterable; } public function filter(?callable $filter = null): self { - $filter = $filter ?? function ($value): bool { - return (bool) $value; - }; + if ($this->iterable instanceof Traversable) { + $filter = $filter ?? + /** @param mixed $value */ + static function ($value): bool { + return (bool) $value; + }; + + return new self(new CallbackFilterIterator(new IteratorIterator($this->iterable), $filter)); + } + + $filtered = $filter === null ? array_filter($this->iterable) : array_filter($this->iterable, $filter); - return new self($this->iterable, $filter, $this->mapFn); + return new self($filtered); } - public function map(callable $map): self + public function map(callable $mapper): self { - return new self($this->iterable, $this->filterFn, $map); + if ($this->iterable instanceof Traversable) { + return new self(new MappedTraversable($this->iterable, $mapper)); + } + + return new self(array_map($mapper, $this->iterable)); } /** @@ -61,30 +62,12 @@ public function map(callable $map): self */ public function getIterator(): Traversable { - $iterable = $this->iterable; - if ($this->filterFn !== null) { - $iterable = iterable_filter($iterable, $this->filterFn); - } - - if ($this->mapFn !== null) { - $iterable = iterable_map($iterable, $this->mapFn); - } - - return iterable_to_traversable($iterable); + yield from $this->iterable; } /** @return array */ public function asArray(): array { - $iterable = $this->iterable; - if ($this->filterFn !== null) { - $iterable = iterable_filter($iterable, $this->filterFn); - } - - if ($this->mapFn !== null) { - $iterable = iterable_map($iterable, $this->mapFn); - } - - return iterable_to_array($iterable); + return $this->iterable instanceof Traversable ? iterator_to_array($this->iterable) : $this->iterable; } } diff --git a/src/MappedTraversable.php b/src/MappedTraversable.php new file mode 100644 index 0000000..d992c76 --- /dev/null +++ b/src/MappedTraversable.php @@ -0,0 +1,41 @@ + + */ +final class MappedTraversable implements IteratorAggregate +{ + /** @var Traversable */ + private $traversable; + + /** @var callable */ + private $mapper; + + /** + * @param Traversable $traversable + */ + public function __construct(Traversable $traversable, callable $mapper) + { + $this->traversable = $traversable; + $this->mapper = $mapper; + } + + /** + * @return Traversable + */ + public function getIterator(): Traversable + { + foreach ($this->traversable as $key => $value) { + yield $key => ($this->mapper)($value); + } + } +} diff --git a/src/iterable-functions.php b/src/iterable-functions.php index 510c335..1fde255 100644 --- a/src/iterable-functions.php +++ b/src/iterable-functions.php @@ -5,12 +5,11 @@ namespace BenTools\IterableFunctions; use ArrayIterator; -use CallbackFilterIterator; -use IteratorIterator; +use EmptyIterator; use Traversable; -use function array_filter; use function array_values; +use function is_array; use function iterator_to_array; /** @@ -22,9 +21,9 @@ */ function iterable_map(iterable $iterable, callable $map): iterable { - foreach ($iterable as $key => $item) { - yield $key => $map($item); - } + $mapped = iterable($iterable)->map($map); + + return is_array($iterable) ? $mapped->asArray() : $mapped; } /** @@ -65,23 +64,13 @@ function iterable_to_traversable(iterable $iterable): Traversable * * @param iterable $iterable * - * @return array|CallbackFilterIterator + * @return iterable */ -function iterable_filter(iterable $iterable, ?callable $filter = null) +function iterable_filter(iterable $iterable, ?callable $filter = null): iterable { - if ($filter === null) { - $filter = - /** @param mixed $value */ - static function ($value): bool { - return (bool) $value; - }; - } - - if ($iterable instanceof Traversable) { - return new CallbackFilterIterator(new IteratorIterator($iterable), $filter); - } + $filtered = iterable($iterable)->filter($filter); - return array_filter($iterable, $filter); + return is_array($iterable) ? $filtered->asArray() : $filtered; } /** @@ -111,7 +100,7 @@ function iterable_reduce(iterable $iterable, callable $reduce, $initial = null) /** * @param iterable $iterable */ -function iterable(iterable $iterable): IterableObject +function iterable(?iterable $iterable): IterableObject { - return IterableObject::new($iterable); + return new IterableObject($iterable ?? new EmptyIterator()); } diff --git a/tests/IterableFilterTest.php b/tests/IterableFilterTest.php index c1a44c5..ae8e034 100644 --- a/tests/IterableFilterTest.php +++ b/tests/IterableFilterTest.php @@ -5,20 +5,24 @@ namespace BenTools\IterableFunctions\Tests; use SplFixedArray; +use Traversable; +use function assert; use function BenTools\IterableFunctions\iterable_filter; -use function BenTools\IterableFunctions\iterable_to_array; use function it; -use function PHPUnit\Framework\assertEquals; +use function iterator_to_array; +use function PHPUnit\Framework\assertSame; it('filters an array', function (): void { $iterable = [false, true]; - assertEquals([1 => true], iterable_to_array(iterable_filter($iterable))); + assertSame([1 => true], iterable_filter($iterable)); }); it('filters a Traversable object', function (): void { $iterable = SplFixedArray::fromArray([false, true]); - assertEquals([1 => true], iterable_to_array(iterable_filter($iterable))); + $filtered = iterable_filter($iterable); + assert($filtered instanceof Traversable); + assertSame([1 => true], iterator_to_array($filtered)); }); it('filters an array with a callback', function (): void { @@ -28,7 +32,7 @@ static function ($input): bool { return $input === 'bar'; }; - assertEquals([1 => 'bar'], iterable_to_array(iterable_filter($iterable, $filter))); + assertSame([1 => 'bar'], iterable_filter($iterable, $filter)); }); it('filters a Travsersable object with a callback', function (): void { @@ -38,5 +42,7 @@ static function ($input): bool { static function ($input): bool { return $input === 'bar'; }; - assertEquals([1 => 'bar'], iterable_to_array(iterable_filter($iterable, $filter))); + $filtered = iterable_filter($iterable, $filter); + assert($filtered instanceof Traversable); + assertSame([1 => 'bar'], iterator_to_array($filtered)); }); diff --git a/tests/IterableMapTest.php b/tests/IterableMapTest.php index 86b3207..c7178f3 100644 --- a/tests/IterableMapTest.php +++ b/tests/IterableMapTest.php @@ -8,24 +8,27 @@ use PHPUnit\Framework\Assert; use SplFixedArray; use stdClass; +use Traversable; +use function assert; use function BenTools\IterableFunctions\iterable_map; -use function BenTools\IterableFunctions\iterable_to_array; use function it; -use function PHPUnit\Framework\assertEquals; +use function iterator_to_array; use function PHPUnit\Framework\assertInstanceOf; use function PHPUnit\Framework\assertSame; it('maps an array', function (): void { $iterable = ['foo', 'bar']; $map = 'strtoupper'; - assertEquals(['FOO', 'BAR'], iterable_to_array(iterable_map($iterable, $map))); + assertSame(['FOO', 'BAR'], iterable_map($iterable, $map)); }); it('maps a Traversable object', function (): void { $iterable = SplFixedArray::fromArray(['foo', 'bar']); $map = 'strtoupper'; - assertEquals(['FOO', 'BAR'], iterable_to_array(iterable_map($iterable, $map))); + $mapped = iterable_map($iterable, $map); + assert($mapped instanceof Traversable); + assertSame(['FOO', 'BAR'], iterator_to_array($mapped)); }); it('maps iterable with object keys', function (): void { diff --git a/tests/IterableObjectTest.php b/tests/IterableObjectTest.php index c3d9377..10fde4c 100644 --- a/tests/IterableObjectTest.php +++ b/tests/IterableObjectTest.php @@ -6,188 +6,114 @@ use BenTools\IterableFunctions\IterableObject; use Generator; -use SplFixedArray; +use IteratorAggregate; +use Traversable; use function array_values; +use function BenTools\CartesianProduct\cartesian_product; use function BenTools\IterableFunctions\iterable; -use function func_num_args; +use function is_callable; use function it; -use function iterator_to_array; use function PHPUnit\Framework\assertEquals; use function PHPUnit\Framework\assertInstanceOf; use function PHPUnit\Framework\assertSame; -use function test; - -$dataProvider = static function (): Generator { - $data = ['foo', 'bar']; - $filter = - /** @param mixed $value */ - static function ($value): bool { - return $value === 'bar'; - }; - $map = 'strtoupper'; - - yield from [ - [ - $data, - null, - null, - ['foo', 'bar'], - ], - [ - $data, - $filter, - null, - [1 => 'bar'], - ], - [ - $data, - null, - $map, - ['FOO', 'BAR'], - ], - [ - $data, - $filter, - $map, - [1 => 'BAR'], - ], - ]; -}; - -/** - * @param iterable $iterable - */ -function create_iterable(iterable $iterable, ?callable $filter = null, ?callable $map = null): IterableObject -{ - $object = iterable($iterable); - - if ($filter !== null && func_num_args() > 1) { - $object = $object->filter($filter); - } - - if ($map !== null) { - $object = $object->map($map); - } - - return $object; -} - -test( - 'input: array | output: traversable', - /** @param array $data */ - function (array $data, ?callable $filter, ?callable $map, array $expectedResult): void { - $iterableObject = create_iterable($data, $filter, $map); - assertEquals($expectedResult, iterator_to_array($iterableObject)); - } -)->with($dataProvider()); - -test( - 'input: array | output: array', - /** @param array $data */ - function (array $data, ?callable $filter, ?callable $map, array $expectedResult): void { - $iterableObject = create_iterable($data, $filter, $map); - assertEquals($expectedResult, $iterableObject->asArray()); - } -)->with($dataProvider()); - -test( - 'input: traversable | output: traversable', - /** @param array $data */ - function (array $data, ?callable $filter, ?callable $map, array $expectedResult): void { - $data = SplFixedArray::fromArray($data); - $iterableObject = create_iterable($data, $filter, $map); - assertEquals($expectedResult, iterator_to_array($iterableObject)); - } -)->with($dataProvider()); - -test( - 'input: traversable | output: array', - /** @param array $data */ - function (array $data, ?callable $filter, ?callable $map, array $expectedResult): void { - $data = SplFixedArray::fromArray($data); - $iterableObject = create_iterable($data, $filter, $map); - assertEquals($expectedResult, $iterableObject->asArray()); - } -)->with($dataProvider()); +use function strtolower; +use function strtoupper; -it('does not filter by default', function (): void { - $data = [ +$combinations = cartesian_product([ + 'input' => [ + null, + ['', 'foo', 'bar'], + new class implements IteratorAggregate { + /** @return Traversable */ + public function getIterator(): Traversable + { + yield ''; + yield 'foo'; + yield 'bar'; + } + }, + ], + 'mapper' => [ null, + static function (): callable { + return static function (string $value): string { + return strtoupper($value); + }; + }, + ], + 'filtered' => [ false, true, - 0, - 1, - '0', - '1', - '', - 'foo', - ]; + ], + 'filter' => [ + null, + static function (): callable { + return static function (string $value): bool { + return strtolower($value) === 'bar'; + }; + }, + ], +]); - $generator = function (array $data): Generator { - yield from $data; - }; - assertSame($data, iterable($data)->asArray()); - assertSame($data, iterable($generator($data))->asArray()); -}); +it( + 'produces the expected result', + function (?iterable $input, ?callable $mapper, bool $filtered, ?callable $filter): void { + $iterable = iterable($input); -it('filters the subject', function (): void { - $filter = - /** @param mixed $value */ - static function ($value): bool { - return $value === 'bar'; - }; - $iterableObject = iterable(['foo', 'bar'])->filter($filter); - assertEquals([1 => 'bar'], iterator_to_array($iterableObject)); -}); + if ($input === null) { + assertSame([], $iterable->asArray()); -it('uses a truthy filter by default when filter() is invoked without arguments', function (): void { - $data = [ - null, - false, - true, - 0, - 1, - '0', - '1', - '', - 'foo', - ]; + return; + } - $truthyValues = [ - true, - 1, - '1', - 'foo', - ]; + // Default expectation + $expected = ['', 'foo', 'bar']; - $generator = function (array $data): Generator { - yield from $data; - }; + // Expectation when iterable is mapped + if ($mapper !== null) { + $iterable = $iterable->map($mapper); + $expected = ['', 'FOO', 'BAR']; + } - assertSame($truthyValues, array_values(iterable($data)->filter()->asArray())); - assertSame($truthyValues, array_values(iterable($generator($data))->filter()->asArray())); -}); + // Expectation when iterable is filtered + if ($filtered === true) { + $iterable = $iterable->filter($filter); -it('maps the subject', function (): void { - $map = 'strtoupper'; - $iterableObject = iterable(['foo', 'bar'])->map($map); - assertInstanceOf(IterableObject::class, $iterableObject); - assertEquals(['FOO', 'BAR'], iterator_to_array($iterableObject)); -}); + // empty string should be removed when iterable is filtered without callable + unset($expected[0]); + + // empty string and "foo" should be removed otherwise + if (is_callable($filter)) { + unset($expected[1]); + } + } + + assertSame($expected, $iterable->asArray()); + } +)->with($combinations); -it('combines filter and map', function (): void { - $filter = - /** @param mixed $value */ - static function ($value): bool { - return $value === 'bar'; +it('can filter first, then map', function (iterable $input): void { + $map = + /** @return mixed */ + static function (string $value) { + $map = ['zero' => 0, 'one' => 1, 'two' => 2]; + + return $map[$value]; }; - $map = 'strtoupper'; - $iterableObject = iterable(['foo', 'bar'])->map($map)->filter($filter); - assertInstanceOf(IterableObject::class, $iterableObject); - assertEquals([1 => 'BAR'], iterator_to_array($iterableObject)); - $iterableObject = iterable(['foo', 'bar'])->filter($filter)->map($map); + $input = ['zero', 'one', 'two']; + + $iterableObject = iterable($input)->filter()->map($map); assertInstanceOf(IterableObject::class, $iterableObject); - assertEquals([1 => 'BAR'], iterator_to_array($iterableObject)); + assertEquals([0, 1, 2], array_values($iterableObject->asArray())); +})->with(function (): Generator { + $input = ['zero', 'one', 'two']; + yield [$input]; + yield [ + /** @return Generator */ + (static function (array $input): Generator { + yield from $input; + })($input), + ]; });