diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..ed17d00 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,12 @@ +# Set the default behavior, in case people don't have core.autocrlf set. +* text eol=lf +/.gitattributes export-ignore +/.gitignore export-ignore +/.github export-ignore +/phpcs.xml.dist export-ignore +/phpstan.neon.dist export-ignore +/phpstan-baseline.neon export-ignore +/phpunit.xml.dist export-ignore +/psalm.xml.dist export-ignore +/psalm-baseline.xml export-ignore +/tests export-ignore diff --git a/.github/workflows/coding-standards.yml b/.github/workflows/coding-standards.yml new file mode 100644 index 0000000..2582648 --- /dev/null +++ b/.github/workflows/coding-standards.yml @@ -0,0 +1,34 @@ +name: "Coding Standards" + +on: + pull_request: + push: + branches: + - "master" + +jobs: + coding-standards: + name: "Coding Standards" + runs-on: "ubuntu-20.04" + + strategy: + matrix: + php-version: + - "7.4" + + steps: + - name: "Checkout" + uses: "actions/checkout@v2" + + - name: "Install PHP" + uses: "shivammathur/setup-php@v2" + with: + coverage: "none" + php-version: "${{ matrix.php-version }}" + tools: "cs2pr" + + - name: "Install dependencies with Composer" + uses: "ramsey/composer-install@v1" + + - name: "Run PHP_CodeSniffer" + run: "vendor/bin/phpcs -q --no-colors --report=checkstyle | cs2pr" diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml new file mode 100644 index 0000000..9e4bf6c --- /dev/null +++ b/.github/workflows/continuous-integration.yml @@ -0,0 +1,72 @@ +name: "Continuous Integration" + +on: + pull_request: + push: + schedule: + - cron: "0 0 1 * *" + +jobs: + unit-tests: + name: "Unit Tests" + runs-on: "ubuntu-20.04" + + strategy: + matrix: + php-version: + - "7.4" + - "8.0" + dependencies: + - "highest" + include: + - dependencies: "lowest" + php-version: "7.4" + + steps: + - name: "Checkout" + uses: "actions/checkout@v2" + with: + fetch-depth: 2 + + - name: "Install PHP" + uses: "shivammathur/setup-php@v2" + with: + php-version: "${{ matrix.php-version }}" + coverage: "pcov" + ini-values: "zend.assertions=1" + + - name: "Install dependencies with Composer" + uses: "ramsey/composer-install@v1" + with: + dependency-versions: "${{ matrix.dependencies }}" + + - name: "Run unit tests" + run: "vendor/bin/pest --coverage-clover=coverage.xml" + + - name: "Upload coverage file" + uses: "actions/upload-artifact@v2" + with: + name: "pest-${{ matrix.deps }}-${{ matrix.php-version }}.coverage" + path: "coverage.xml" + + upload-coverage: + name: "Upload coverage to Codecov" + runs-on: "ubuntu-20.04" + needs: + - "unit-tests" + + steps: + - name: "Checkout" + uses: "actions/checkout@v2" + with: + fetch-depth: 2 + + - name: "Download coverage files" + uses: "actions/download-artifact@v2" + with: + path: "reports" + + - name: "Upload to Codecov" + uses: "codecov/codecov-action@v1" + with: + directory: reports diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml new file mode 100644 index 0000000..23cd0e1 --- /dev/null +++ b/.github/workflows/static-analysis.yml @@ -0,0 +1,58 @@ +name: "Static Analysis" + +on: + pull_request: + push: + branches: + - "master" + +jobs: + static-analysis-phpstan: + name: "Static Analysis with PHPStan" + runs-on: "ubuntu-20.04" + + strategy: + matrix: + php-version: + - "7.4" + + steps: + - name: "Checkout code" + uses: "actions/checkout@v2" + + - name: "Install PHP" + uses: "shivammathur/setup-php@v2" + with: + coverage: "none" + php-version: "${{ matrix.php-version }}" + tools: "cs2pr" + + - name: "Install dependencies with Composer" + uses: "ramsey/composer-install@v1" + + - name: "Run a static analysis with phpstan/phpstan" + run: "vendor/bin/phpstan analyse --error-format=checkstyle | cs2pr" + + static-analysis-psalm: + name: "Static Analysis with Psalm" + runs-on: "ubuntu-20.04" + + strategy: + matrix: + php-version: + - "7.4" + + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Psalm + uses: docker://vimeo/psalm-github-actions:4.4.1 + with: + composer_require_dev: true + security_analysis: true + report_file: results.sarif + - name: Upload Security Analysis results to GitHub + uses: github/codeql-action/upload-sarif@v1 + with: + sarif_file: results.sarif diff --git a/.gitignore b/.gitignore index c23da7c..56ed85a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,8 @@ -/vendor - +/.phpcs-cache +/.phpunit.result.cache /composer.lock +/phpcs.xml +/phpstan.neon +/psalm.xml /phpunit.xml +/vendor/ diff --git a/.scrutinizer.yml b/.scrutinizer.yml deleted file mode 100644 index 5f9e672..0000000 --- a/.scrutinizer.yml +++ /dev/null @@ -1,32 +0,0 @@ -filter: - excluded_paths: [tests/*] -checks: - php: - code_rating: true - remove_extra_empty_lines: true - remove_php_closing_tag: true - remove_trailing_whitespace: true - fix_use_statements: - remove_unused: true - preserve_multiple: false - preserve_blanklines: true - order_alphabetically: true - fix_php_opening_tag: true - fix_linefeed: true - fix_line_ending: true - fix_identation_4spaces: true - fix_doc_comments: true -tools: - php_analyzer: true - php_code_coverage: false - php_code_sniffer: - config: - standard: PSR2 - filter: - paths: ['src'] - php_loc: - enabled: true - excluded_dirs: [vendor, tests] - php_cpd: - enabled: true - excluded_dirs: [vendor, tests] diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index d2408be..0000000 --- a/.travis.yml +++ /dev/null @@ -1,37 +0,0 @@ -language: php - -php: - - '5.6' - - '7.0' - - '7.1' - - '7.2' - - '7.3' - - '7.4' - - 'nightly' - -matrix: - include: - - php: '5.3' - dist: precise - - php: '5.4' - dist: trusty - - php: '5.5' - dist: trusty - allow_failures: - - php: 'nightly' - -before_script: - - travis_retry composer self-update - - travis_retry composer global require hirak/prestissimo - - travis_retry composer install --no-interaction --prefer-dist - - travis_retry phpenv rehash - -script: - - if [[ ${TRAVIS_PHP_VERSION:0:3} == "5.3" ]]; then composer require --dev symfony/polyfill-php54; fi - - if [[ $TRAVIS_PHP_VERSION = 5.5.* || $TRAVIS_PHP_VERSION = 5.6.* || $TRAVIS_PHP_VERSION = 7.* ]]; then composer require --dev php-coveralls/php-coveralls; fi - - if [[ $TRAVIS_PHP_VERSION = 5.5.* || $TRAVIS_PHP_VERSION = 5.6.* || $TRAVIS_PHP_VERSION = 7.* ]]; then ./vendor/bin/phpunit --coverage-clover build/logs/clover.xml; else ./vendor/bin/phpunit; fi - - ./vendor/bin/phpcs --standard=psr2 -n src/ - - mkdir -p build/logs - -after_script: - - php vendor/bin/php-coveralls -v diff --git a/README.md b/README.md index 1bb43b4..d1a5801 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,20 @@ [![Latest Stable Version](https://poser.pugx.org/bentools/iterable-functions/v/stable)](https://packagist.org/packages/bentools/iterable-functions) -[![License](https://poser.pugx.org/bentools/iterable-functions/license)](https://packagist.org/packages/bentools/iterable-functions) -[![Build Status](https://img.shields.io/travis/bpolaszek/php-iterable-functions/master.svg?style=flat-square)](https://travis-ci.org/bpolaszek/php-iterable-functions) -[![Coverage Status](https://coveralls.io/repos/github/bpolaszek/php-iterable-functions/badge.svg?branch=master)](https://coveralls.io/github/bpolaszek/php-iterable-functions?branch=master) -[![Quality Score](https://img.shields.io/scrutinizer/g/bpolaszek/php-iterable-functions.svg?style=flat-square)](https://scrutinizer-ci.com/g/bpolaszek/php-iterable-functions) +[![GitHub Actions][GA master image]][GA master] +[![Code Coverage][Coverage image]][CodeCov Master] +[![Shepherd Type][Shepherd Image]][Shepherd Link] [![Total Downloads](https://poser.pugx.org/bentools/iterable-functions/downloads)](https://packagist.org/packages/bentools/iterable-functions) Iterable functions ================== -Provides additional functions to work with [iterable](https://wiki.php.net/rfc/iterable) variables (even on PHP5.3+). +This package provides functions to work with [iterables](https://wiki.php.net/rfc/iterable), as you usually do with arrays: -is_iterable() -------------- -To check wether or not a PHP variable can be looped over in a `foreach` statement, PHP provides an `is_iterable()` function. - -**But this function only works on PHP7.1+**. - -This library ships a polyfill of this function for previous PHP versions. - -Usage: -```php -var_dump(is_iterable(array('foo', 'bar'))); // true -var_dump(is_iterable(new DirectoryIterator(__DIR__))); // true -var_dump(is_iterable('foobar')); // false -``` +- [iterable_to_array()](#iterable_to_array) +- [iterable_to_traversable()](#iterable_to_traversable) +- [iterable_map()](#iterable_map) +- [iterable_reduce()](#iterable_reduce) +- [iterable_filter()](#iterable_filter) +- [iterable_values()](#iterable_values) iterable_to_array() ------------------- @@ -32,13 +23,15 @@ PHP offers an `iterator_to_array()` function to export any iterator into an arra **But when you want to transform an `iterable` to an array, the `iterable` itself can already be an array.** -When using `iterator_to_array()` with an array, PHP5 triggers a E_RECOVERABLE_ERROR while PHP7 throws a `TypeError`. +When using `iterator_to_array()` with an iterable, that happens to be an array, PHP will throw a `TypeError`. If you need an iterable-agnostic function, try our `iterable_to_array()`: ```php -var_dump(iterable_to_array(new ArrayIterator(array('foo', 'bar')))); // ['foo', 'bar'] -var_dump(iterable_to_array(array('foo', 'bar'))); // ['foo', 'bar'] +use function BenTools\IterableFunctions\iterable_to_array; + +var_dump(iterable_to_array(new \ArrayIterator(['foo', 'bar']))); // ['foo', 'bar'] +var_dump(iterable_to_array(['foo', 'bar'])); // ['foo', 'bar'] ``` iterable_to_traversable() @@ -50,11 +43,13 @@ If your variable is already an instance of `Traversable` (i.e. an `Iterator`, an If your variable is an array, the function converts it to an `ArrayIterator`. Usage: + ```php -var_dump(iterable_to_traversable(array('foo', 'bar'))); // ArrayIterator(array('foo', 'bar')) -var_dump(iterable_to_traversable(new ArrayIterator(array('foo', 'bar')))); // ArrayIterator(array('foo', 'bar')) -``` +use function BenTools\IterableFunctions\iterable_to_traversable; +var_dump(iterable_to_traversable(['foo', 'bar'])); // \ArrayIterator(['foo', 'bar']) +var_dump(iterable_to_traversable(new \ArrayIterator(['foo', 'bar']))); // \ArrayIterator(['foo', 'bar']) +``` iterable_map() -------------- @@ -62,6 +57,8 @@ iterable_map() Works like an `array_map` with an `array` or a `Traversable`. ```php +use function BenTools\IterableFunctions\iterable_map; + $generator = function () { yield 'foo'; yield 'bar'; @@ -78,6 +75,8 @@ iterable_reduce() Works like an `reduce` with an `iterable`. ```php +use function BenTools\IterableFunctions\iterable_reduce; + $generator = function () { yield 1; yield 2; @@ -98,6 +97,8 @@ iterable_filter() Works like an `array_filter` with an `array` or a `Traversable`. ```php +use function BenTools\IterableFunctions\iterable_filter; + $generator = function () { yield 0; yield 1; @@ -109,7 +110,10 @@ foreach (iterable_filter($generator()) as $item) { ``` Of course you can define your own filter: + ```php +use function BenTools\IterableFunctions\iterable_filter; + $generator = function () { yield 'foo'; yield 'bar'; @@ -125,69 +129,89 @@ foreach (iterable_filter($generator(), $filter) as $item) { } ``` +iterable_values() +-------------- -Iterable factory -================ - -When you have an `iterable` type-hint somewhere, and don't know in advance wether you'll pass an `array` or a `Traversable`, just call the magic `iterable()` factory: +Works like an `array_values` with an `array` or a `Traversable`. ```php -interface SomeInterface -{ - /** - * Return an iterable list of items - * - * @return iterable - */ - public function getItems(): iterable; -} +use function BenTools\IterableFunctions\iterable_values; -class MyService implements SomeInterface -{ - /** - * @inheritdoc - */ - public function getItems(): iterable - { - return iterable($this->someOtherService->findAll()): - } +$generator = function () { + yield 'a' => 'a'; + yield 'b' => 'b'; +}; +foreach (iterable_values($generator()) as $key => $value) { + var_dump($key); // 0, 1 + var_dump($value); // a, b } ``` -It even accepts a `null` value (then converting it to an `EmptyIterator`). +Iterable fluent interface +========================= + +The `iterable` function allows you to wrap an iterable and apply some common operations. -You may add a `filter` callable and a `map` callable to make your life easier: +With an array input: ```php +use function BenTools\IterableFunctions\iterable; $data = [ 'banana', 'pineapple', - 'potato', + 'rock', ]; -$isFruit = function ($eatable) { - return 'potato' !== $eatable; -}; +$iterable = iterable($data)->filter(fn($eatable) => 'rock' !== $eatable)->map('strtoupper'); // Traversable of ['banana', 'pineapple'] +``` -var_dump(iterator_to_array(iterable($data)->filter($isFruit)->map('strtoupper'))); // ['banana', 'pineapple'] +With a traversable input: + +```php +use function BenTools\IterableFunctions\iterable; +$data = [ + 'banana', + 'pineapple', + 'rock', +]; + +$data = fn() => yield from $data; + +$iterable = iterable($data())->filter(fn($eatable) => 'rock' !== $eatable)->map('strtoupper'); // Traversable of ['banana', 'pineapple'] +``` + +Array output: + +```php +$iterable->asArray(); // array ['banana', 'pineapple'] ``` Installation ============ -With composer (they'll be autoloaded): ``` -composer require bentools/iterable-functions +composer require bentools/iterable-functions:^2.0 ``` -Or manually: -```php -require_once '/path/to/this/library/src/iterable-functions.php'; -``` +For PHP5+ compatibility, check out the [1.x branch](https://github.com/bpolaszek/php-iterable-functions/tree/1.x). + Unit tests ========== + ``` -./vendor/bin/phpunit +php vendor/bin/pest ``` + +[GA master]: https://github.com/bpolaszek/php-iterable-functions/actions?query=workflow%3A%22Continuous+Integration%22+branch%3A2.0.x-dev + +[GA master image]: https://github.com/bpolaszek/php-iterable-functions/workflows/Continuous%20Integration/badge.svg + +[CodeCov Master]: https://codecov.io/gh/bpolaszek/php-iterable-functions/branch/2.0.x-dev + +[Coverage image]: https://codecov.io/gh/bpolaszek/php-iterable-functions/branch/2.0.x-dev/graph/badge.svg + +[Shepherd Image]: https://shepherd.dev/github/bpolaszek/php-iterable-functions/coverage.svg + +[Shepherd Link]: https://shepherd.dev/github/bpolaszek/php-iterable-functions diff --git a/composer.json b/composer.json index 31041a7..b04bdf7 100644 --- a/composer.json +++ b/composer.json @@ -10,16 +10,27 @@ "files": ["src/iterable-functions.php"] }, "autoload-dev": { + "psr-4": { + "BenTools\\IterableFunctions\\Tests\\": "tests" + }, "files": [ "vendor/symfony/var-dumper/Resources/functions/dump.php" ] }, "require": { - "php": ">=5.3" + "php": "^7.4 || ^8.0" }, "require-dev": { - "phpunit/phpunit": "^4.0|^5.0|^6.0|^7.0", - "squizlabs/php_codesniffer": "^2.0|^3.4", - "symfony/var-dumper": "@stable" + "bentools/cartesian-product": "^1.3", + "doctrine/coding-standard": "^8.2", + "pestphp/pest": "^1.0", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "^0.12.67", + "phpstan/phpstan-strict-rules": "^0.12.9", + "symfony/var-dumper": "^5.2", + "vimeo/psalm": "^4.4" + }, + "config": { + "sort-packages": true } } diff --git a/phpcs.xml.dist b/phpcs.xml.dist new file mode 100644 index 0000000..82c4a0e --- /dev/null +++ b/phpcs.xml.dist @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + src/ + tests/ + diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..1e018d5 --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,6 @@ +parameters: + level: max + paths: + - %currentWorkingDirectory%/src + - %currentWorkingDirectory%/tests + diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 9ca55d7..9247927 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,37 +1,21 @@ - - tests/TestIsIterable.php - tests/TestIterableToArray.php - tests/TestIterableToTraversable.php - tests/TestIterableFilter.php - tests/TestIterableMap.php - tests/TestIterableObject.php + tests - - - ./src - - src/iterable-map-php53.php - src/iterable-map-php55.php - - - + + + src + + diff --git a/psalm-baseline.xml b/psalm-baseline.xml new file mode 100644 index 0000000..555826d --- /dev/null +++ b/psalm-baseline.xml @@ -0,0 +1,13 @@ + + + + + is_array($iterable) ? $filtered->asArray() : $filtered + is_array($iterable) ? $mapped->asArray() : $mapped + + + iterable<TKey, TResult> + iterable<TKey, TValue> + + + diff --git a/psalm.xml.dist b/psalm.xml.dist new file mode 100644 index 0000000..02ceeb4 --- /dev/null +++ b/psalm.xml.dist @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/IterableObject.php b/src/IterableObject.php index c0caeea..c3526f1 100644 --- a/src/IterableObject.php +++ b/src/IterableObject.php @@ -1,131 +1,92 @@ + */ final class IterableObject implements IteratorAggregate { - /** - * @var iterable|array|Traversable - */ + /** @var iterable */ private $iterable; - /** - * @var callable - */ - private $filter; - - /** - * @var callable - */ - private $map; + /** @param iterable $iterable */ + public function __construct(iterable $iterable) + { + $this->iterable = $iterable; + } /** - * IterableObject constructor. - * @param iterable|array|Traversable $iterable - * @param callable|null $filter - * @param callable|null $map - * @throws InvalidArgumentException + * @param (callable(TValue):bool)|null $filter + * + * @return self */ - public function __construct($iterable, $filter = null, $map = null) + public function filter(?callable $filter = null): self { - if (null === $iterable) { - $iterable = new EmptyIterator(); - } - if (!is_iterable($iterable)) { - throw new InvalidArgumentException( - sprintf('Expected array or Traversable, got %s', is_object($iterable) ? get_class($iterable) : gettype($iterable)) - ); - } - - // Cannot rely on callable type-hint on PHP 5.3 - if (null !== $filter && !is_callable($filter) && !$filter instanceof Closure) { - throw new InvalidArgumentException( - sprintf('Expected callable, got %s', is_object($filter) ? get_class($filter) : gettype($filter)) - ); - } + if ($this->iterable instanceof Traversable) { + $filter ??= + /** @param mixed $value */ + static function ($value): bool { + return (bool) $value; + }; - if (null !== $map && !is_callable($map) && !$map instanceof Closure) { - throw new InvalidArgumentException( - sprintf('Expected callable, got %s', is_object($map) ? get_class($map) : gettype($map)) - ); + return new self(new CallbackFilterIterator(new IteratorIterator($this->iterable), $filter)); } - $this->iterable = $iterable; - $this->filter = $filter; - $this->map = $map; - } + $filtered = $filter === null ? array_filter($this->iterable) : array_filter($this->iterable, $filter); - /** - * @param callable $filter - * @return self - */ - public function filter($filter) - { - return new self($this->iterable, $filter, $this->map); + return new self($filtered); } /** - * @param callable $map - * @return self + * @param callable(TValue):TResult $mapper + * + * @return self + * + * @template TResult */ - public function map($map) + public function map(callable $mapper): self { - return new self($this->iterable, $this->filter, $map); - } + if ($this->iterable instanceof Traversable) { + return new self(new MappedTraversable($this->iterable, $mapper)); + } - /** - * @param callable $filter - * @return self - * @deprecated Use IterableObject::filter instead. - */ - public function withFilter($filter) - { - return $this->filter($filter); + return new self(array_map($mapper, $this->iterable)); } /** - * @param callable $map - * @return self - * @deprecated Use IterableObject::map instead. + * @return self */ - public function withMap($map) + public function values(): self { - return $this->map($map); + return new self(new WithoutKeysTraversable($this->iterable)); } - /** - * @inheritdoc - */ - public function getIterator() + /** @return Traversable */ + public function getIterator(): Traversable { - $iterable = $this->iterable; - if (null !== $this->filter) { - $iterable = iterable_filter($iterable, $this->filter); - } - if (null !== $this->map) { - $iterable = iterable_map($iterable, $this->map); - } - return iterable_to_traversable($iterable); + yield from $this->iterable; } - /** - * @return array - */ - public function asArray() + /** @return array */ + public function asArray(): array { - $iterable = $this->iterable; - if (null !== $this->filter) { - $iterable = iterable_filter($iterable, $this->filter); - } - if (null !== $this->map) { - $iterable = iterable_map($iterable, $this->map); - } - 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/WithoutKeysTraversable.php b/src/WithoutKeysTraversable.php new file mode 100644 index 0000000..6a97776 --- /dev/null +++ b/src/WithoutKeysTraversable.php @@ -0,0 +1,39 @@ + + */ +final class WithoutKeysTraversable implements IteratorAggregate +{ + /** @var iterable */ + private $iterable; + + /** + * @param iterable $iterable + */ + public function __construct(iterable $iterable) + { + $this->iterable = $iterable; + } + + /** + * @return Generator + */ + public function getIterator(): Generator + { + foreach ($this->iterable as $value) { + yield $value; + } + } +} diff --git a/src/iterable-functions.php b/src/iterable-functions.php index de15eeb..981332e 100644 --- a/src/iterable-functions.php +++ b/src/iterable-functions.php @@ -1,145 +1,137 @@ = 0) { - include_once __DIR__ . '/iterable-map-php55.php'; -} else { - include_once __DIR__ . '/iterable-map-php53.php'; -} - -if (!function_exists('is_iterable')) { +namespace BenTools\IterableFunctions; - /** - * Check wether or not a variable is iterable (i.e array or \Traversable) - * - * @param mixed $iterable - * @return bool - */ - function is_iterable($iterable) - { - return is_array($iterable) || $iterable instanceof \Traversable; - } -} +use ArrayIterator; +use EmptyIterator; +use Traversable; -if (!function_exists('iterable_to_array')) { +use function array_values; +use function iterator_to_array; - /** - * Copy the iterable into an array. If the iterable is already an array, return it. - * - * @param iterable|array|\Traversable $iterable - * @param bool $use_keys [optional] Whether to use the iterator element keys as index. - * @return array - */ - function iterable_to_array($iterable, $use_keys = true) - { - return is_array($iterable) ? ($use_keys ? $iterable : array_values($iterable)) : iterator_to_array($iterable, $use_keys); - } +/** + * Maps a callable to an iterable. + * + * @param iterable $iterable + * @param callable(TValue):TResult $mapper + * + * @return iterable + * + * @template TKey + * @template TValue + * @template TResult + */ +function iterable_map(iterable $iterable, callable $mapper): iterable +{ + return iterable($iterable)->map($mapper); } -if (!function_exists('iterable_to_traversable')) { - - /** - * If the iterable is not intance of \Traversable, it is an array => convert it to an ArrayIterator. - * - * @param iterable|array|\Traversable $iterable - * @return \Traversable - */ - function iterable_to_traversable($iterable) - { - if ($iterable instanceof Traversable) { - return $iterable; - } elseif (is_array($iterable)) { - return new ArrayIterator($iterable); - } else { - throw new \InvalidArgumentException( - sprintf( - 'Expected array or \\Traversable, got %s', - is_object($iterable) ? get_class($iterable) : gettype($iterable) - ) - ); - } +/** + * Copy the iterable into an array. + * + * @param iterable $iterable + * @param bool $preserveKeys [optional] Whether to use the iterator element keys as index. + * + * @return array + * + * @psalm-return ($preserveKeys is true ? array : array) + * @psalm-template TKey as array-key + * @phpstan-template TKey + * @template TValue + */ +function iterable_to_array(iterable $iterable, bool $preserveKeys = true): array +{ + if ($iterable instanceof Traversable) { + return iterator_to_array($iterable, $preserveKeys); } -} - -if (!function_exists('iterable_filter')) { - /** - * Filters an iterable. - * - * @param iterable|array|\Traversable $iterable - * @param callable $filter - * @return array|CallbackFilterIterator - * @throws InvalidArgumentException - */ - function iterable_filter($iterable, $filter = null) - { - if (!is_iterable($iterable)) { - throw new \InvalidArgumentException( - sprintf('Expected array or Traversable, got %s', is_object($iterable) ? get_class($iterable) : gettype($iterable)) - ); - } + return $preserveKeys ? $iterable : array_values($iterable); +} - // Cannot rely on callable type-hint on PHP 5.3 - if (null !== $filter && !is_callable($filter) && !$filter instanceof Closure) { - throw new InvalidArgumentException( - sprintf('Expected callable, got %s', is_object($filter) ? get_class($filter) : gettype($filter)) - ); - } +/** + * If the iterable is not instance of Traversable, it is an array => convert it to an ArrayIterator. + * + * @param iterable $iterable + * + * @return Traversable + * + * @template TKey + * @template TValue + */ +function iterable_to_traversable(iterable $iterable): Traversable +{ + if ($iterable instanceof Traversable) { + return $iterable; + } - if (null === $filter) { - $filter = function ($value) { - return (bool) $value; - }; - } + return new ArrayIterator($iterable); +} - if ($iterable instanceof Traversable) { - if (!class_exists('CallbackFilterIterator')) { - throw new \RuntimeException('Class CallbackFilterIterator not found. Try using a polyfill, like symfony/polyfill-php54'); - } - return new CallbackFilterIterator(new IteratorIterator($iterable), $filter); - } +/** + * Filters an iterable. + * + * @param (callable(TValue):bool)|null $filter + * + * @psalm-param iterable $iterable + * @phpstan-param iterable $iterable https://github.com/phpstan/phpstan/issues/4498 + * @psalm-return iterable + * @phpstan-return iterable https://github.com/phpstan/phpstan/issues/4498 + * @template TKey + * @template TValue + */ +function iterable_filter(iterable $iterable, ?callable $filter = null): iterable +{ + return iterable($iterable)->filter($filter); +} - return array_filter($iterable, $filter); +/** + * Reduces an iterable. + * + * @param iterable $iterable + * @param TResult $initial + * @param callable(TResult, TValue):TResult $reduce + * + * @return TResult + * + * @template TValue + * @template TResult + * @phpcsSuppress SlevomatCodingStandard.TypeHints.ParameterTypeHint.MissingNativeTypeHint + * @phpcsSuppress SlevomatCodingStandard.TypeHints.ReturnTypeHint.MissingNativeTypeHint + */ +function iterable_reduce(iterable $iterable, callable $reduce, $initial = null) +{ + foreach ($iterable as $item) { + $initial = $reduce($initial, $item); } + return $initial; } -if (!function_exists('iterable_reduce')) { - /** - * Reduces an iterable. - * - * @param iterable $iterable - * @param callable(mixed, mixed) $reduce - * @return mixed - * - * @psalm-template TValue - * @psalm-template TResult - * - * @psalm-param iterable $iterable - * @psalm-param callable(TResult|null, TValue) $reduce - * @psalm-param TResult|null $initial - * - * @psalm-return TResult|null - */ - function iterable_reduce($iterable, $reduce, $initial = null) - { - foreach ($iterable as $item) { - $initial = $reduce($initial, $item); - } - - return $initial; - } +/** + * Yields iterable values (leaving out keys). + * + * @param iterable $iterable + * + * @return iterable + * + * @template TValue + */ +function iterable_values(iterable $iterable): iterable +{ + return iterable($iterable)->values(); } /** - * @param iterable|array|\Traversable $iterable - * @param callable|null $filter - * @param callable|null $map - * @return Traversable|IterableObject - * @throws InvalidArgumentException + * @param iterable|null $iterable + * + * @return IterableObject + * + * @template TKey + * @template TValue */ -function iterable($iterable, $filter = null, $map = null) +function iterable(?iterable $iterable): IterableObject { - return new IterableObject($iterable, $filter, $map); + return new IterableObject($iterable ?? new EmptyIterator()); } diff --git a/src/iterable-map-php53.php b/src/iterable-map-php53.php deleted file mode 100644 index 3918de9..0000000 --- a/src/iterable-map-php53.php +++ /dev/null @@ -1,35 +0,0 @@ - $value) { - yield $key => $map($value); - } - }; - - return $generator($iterable, $map); - } - - return array_map($map, $iterable); - } - -} diff --git a/tests/IterableFilterTest.php b/tests/IterableFilterTest.php new file mode 100644 index 0000000..ea0b4ca --- /dev/null +++ b/tests/IterableFilterTest.php @@ -0,0 +1,49 @@ + true], iterable_to_array(iterable_filter($iterable))); +}); + +it('filters a Traversable object', function (): void { + $iterable = SplFixedArray::fromArray([false, true]); + $filtered = iterable_filter($iterable); + assert($filtered instanceof Traversable); + assertSame([1 => true], iterator_to_array($filtered)); +}); + +it('filters an array with a callback', function (): void { + $iterable = ['foo', 'bar']; + $filter = + /** @param mixed $input */ + static function ($input): bool { + return $input === 'bar'; + }; + assertSame([1 => 'bar'], iterable_to_array(iterable_filter($iterable, $filter))); +}); + +it('filters a Travsersable object with a callback', function (): void { + $iterable = SplFixedArray::fromArray(['foo', 'bar']); + $filter = + /** @param mixed $input */ + static function ($input): bool { + return $input === 'bar'; + }; + $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 new file mode 100644 index 0000000..618a164 --- /dev/null +++ b/tests/IterableMapTest.php @@ -0,0 +1,49 @@ + $item) { + assertInstanceOf(stdClass::class, $key); + assertSame('FOO', $item); + + return; + } + + Assert::fail('Did not iterate'); +}); + +/** @return Generator */ +function iterableWithObjectKeys(): Generator +{ + yield new stdClass() => 'foo'; +} diff --git a/tests/IterableObjectTest.php b/tests/IterableObjectTest.php new file mode 100644 index 0000000..ded2efe --- /dev/null +++ b/tests/IterableObjectTest.php @@ -0,0 +1,126 @@ + [ + 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, + ], + 'filter' => [ + null, + static function (): callable { + return static function (string $value): bool { + return strtolower($value) === 'bar'; + }; + }, + ], +]); + + +it( + 'produces the expected result', + function (?iterable $input, ?callable $mapper, bool $filtered, ?callable $filter): void { + $iterable = iterable($input); + + if ($input === null) { + assertSame([], $iterable->asArray()); + + return; + } + + // Default expectation + $expected = ['', 'foo', 'bar']; + + // Expectation when iterable is mapped + if ($mapper !== null) { + $iterable = $iterable->map($mapper); + $expected = ['', 'FOO', 'BAR']; + } + + // Expectation when iterable is filtered + if ($filtered === true) { + $iterable = $iterable->filter($filter); + + // 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('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]; + }; + + $iterableObject = iterable($input)->filter()->map($map); + assertInstanceOf(IterableObject::class, $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), + ]; +}); + +it('strips key through values()', function (): void { + $input = ['x' => 'zero', 'y' => 'one', 'z' => 'two']; + + $iterableObject = iterable($input)->values(); + assertInstanceOf(IterableObject::class, $iterableObject); + assertEquals(['zero', 'one', 'two'], $iterableObject->asArray()); +}); diff --git a/tests/IterableReduceTest.php b/tests/IterableReduceTest.php new file mode 100644 index 0000000..0001cc4 --- /dev/null +++ b/tests/IterableReduceTest.php @@ -0,0 +1,27 @@ + 'foo', 2 => 'bar']); + assertEquals([0 => 'foo', 1 => 'bar'], iterable_to_array($iterator, false)); +}); + +it('keeps the same array', function (): void { + $array = ['foo', 'bar']; + assertSame(['foo', 'bar'], iterable_to_array($array)); +}); + +it('removes the keys of an array', function (): void { + $array = [1 => 'foo', 2 => 'bar']; + assertEquals([0 => 'foo', 1 => 'bar'], iterable_to_array($array, false)); +}); diff --git a/tests/IterableToTraversableTest.php b/tests/IterableToTraversableTest.php new file mode 100644 index 0000000..688aa57 --- /dev/null +++ b/tests/IterableToTraversableTest.php @@ -0,0 +1,24 @@ + 'bar']); + $traversable = iterable_to_traversable($iterator); + assertSame($iterator, $traversable); +}); + +it('converts an array to a traversable object', function (): void { + $array = ['foo' => 'bar']; + $traversable = iterable_to_traversable($array); + assertEquals(new ArrayIterator(['foo' => 'bar']), $traversable); +}); diff --git a/tests/IterableValuesTest.php b/tests/IterableValuesTest.php new file mode 100644 index 0000000..4a0c55b --- /dev/null +++ b/tests/IterableValuesTest.php @@ -0,0 +1,48 @@ + true]; + + // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedForeach + foreach (iterable_values($iterable) as $key => $value) { + } + + if (! isset($key, $value)) { + Assert::fail('No values were returned'); + } + + assertSame(0, $key); + assertSame(true, $value); + } +); + +it( + 'gets values of Traversable object', + function (): void { + $iterable = new ArrayIterator(['b' => true]); + + // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedForeach + foreach (iterable_values($iterable) as $key => $value) { + } + + if (! isset($key, $value)) { + Assert::fail('No values were returned'); + } + + assertSame(0, $key); + assertSame(true, $value); + } +); diff --git a/tests/TestIsIterable.php b/tests/TestIsIterable.php deleted file mode 100644 index 8426ed8..0000000 --- a/tests/TestIsIterable.php +++ /dev/null @@ -1,43 +0,0 @@ -assertTrue(function_exists('is_iterable')); - } - - public function testArrayIsIterable() - { - $array = array('foo', 'bar'); - $this->assertTrue(is_iterable($array)); - } - - public function testIteratorIsIterable() - { - $iterator = new DirectoryIterator(__DIR__); - $this->assertTrue(is_iterable($iterator)); - } - - public function testScalarIsNotIterable() - { - $scalar = 'foobar'; - $this->assertFalse(is_iterable($scalar)); - } - - public function testObjectIsNotIterable() - { - $object = new \stdClass(); - $this->assertFalse(is_iterable($object)); - } - - public function testResourceIsNotIterable() - { - $resource = fopen('php://temp', 'rb'); - $this->assertFalse(is_iterable($resource)); - } - -} diff --git a/tests/TestIterableFilter.php b/tests/TestIterableFilter.php deleted file mode 100644 index cd9d22e..0000000 --- a/tests/TestIterableFilter.php +++ /dev/null @@ -1,34 +0,0 @@ -assertEquals(array(1 => 'bar'), iterable_to_array(iterable_filter($iterable, $filter))); - } - - public function testTraversableFilter() - { - $iterable = SplFixedArray::fromArray(array('foo', 'bar')); - $filter = function ($input) { - return 'bar' === $input; - }; - $this->assertEquals(array(1 => 'bar'), iterable_to_array(iterable_filter($iterable, $filter))); - } - - /** - * @expectedException InvalidArgumentException - */ - public function testInvalidIterable() - { - $filter = function () { - return true; - }; - iterable_filter('foo', $filter); - } -} \ No newline at end of file diff --git a/tests/TestIterableMap.php b/tests/TestIterableMap.php deleted file mode 100644 index 26d2811..0000000 --- a/tests/TestIterableMap.php +++ /dev/null @@ -1,30 +0,0 @@ -assertEquals(array('FOO', 'BAR'), iterable_to_array(iterable_map($iterable, $map))); - } - - public function testTraversableMap() - { - $iterable = SplFixedArray::fromArray(array('foo', 'bar')); - $map = 'strtoupper'; - $this->assertEquals(array('FOO', 'BAR'), iterable_to_array(iterable_map($iterable, $map))); - } - - /** - * @expectedException InvalidArgumentException - */ - public function testInvalidIterable() - { - $filter = function () { - return true; - }; - iterable_map('foo', $filter); - } -} \ No newline at end of file diff --git a/tests/TestIterableObject.php b/tests/TestIterableObject.php deleted file mode 100644 index 108a4ee..0000000 --- a/tests/TestIterableObject.php +++ /dev/null @@ -1,114 +0,0 @@ -assertInstanceOf('BenTools\IterableFunctions\IterableObject', $iterableObject); - $this->assertEquals($expectedResult, iterator_to_array($iterableObject)); - } - /** - * @dataProvider dataProvider - */ - public function testFromArrayToArray($data, $filter = null, $map = null, $expectedResult) - { - $iterableObject = iterable($data, $filter, $map); - $this->assertInstanceOf('BenTools\IterableFunctions\IterableObject', $iterableObject); - $this->assertEquals($expectedResult, $iterableObject->asArray()); - } - - /** - * @dataProvider dataProvider - */ - public function testFromTraversableToIterator($data, $filter = null, $map = null, $expectedResult) - { - $data = SplFixedArray::fromArray($data); - $iterableObject = iterable($data, $filter, $map); - $this->assertInstanceOf('BenTools\IterableFunctions\IterableObject', $iterableObject); - $this->assertEquals($expectedResult, iterator_to_array($iterableObject)); - } - /** - * @dataProvider dataProvider - */ - public function testFromTraversableToArray($data, $filter = null, $map = null, $expectedResult) - { - $data = SplFixedArray::fromArray($data); - $iterableObject = iterable($data, $filter, $map); - $this->assertInstanceOf('BenTools\IterableFunctions\IterableObject', $iterableObject); - $this->assertEquals($expectedResult, $iterableObject->asArray()); - } - - public function testFilterMutator() - { - $filter = function ($value) { - return 'bar' === $value; - }; - $iterableObject = iterable(array('foo', 'bar'))->withFilter($filter); - $this->assertEquals(array(1 => 'bar'), iterator_to_array($iterableObject)); - } - - public function testMapMutator() - { - $map = 'strtoupper'; - $iterableObject = iterable(array('foo', 'bar'))->withMap($map); - $this->assertEquals(array('FOO', 'BAR'), iterator_to_array($iterableObject)); - } - - public function testFilterAndMapMutators() - { - $filter = function ($value) { - return 'bar' === $value; - }; - $map = 'strtoupper'; - $iterableObject = iterable(array('foo', 'bar'))->withMap($map)->withFilter($filter); - $this->assertEquals(array(1 => 'BAR'), iterator_to_array($iterableObject)); - $iterableObject = iterable(array('foo', 'bar'))->map($map)->filter($filter); - $this->assertEquals(array(1 => 'BAR'), iterator_to_array($iterableObject)); - $iterableObject = iterable(array('foo', 'bar'))->withFilter($filter)->withMap($map); - $this->assertEquals(array(1 => 'BAR'), iterator_to_array($iterableObject)); - $iterableObject = iterable(array('foo', 'bar'))->filter($filter)->map($map); - $this->assertEquals(array(1 => 'BAR'), iterator_to_array($iterableObject)); - } - - public function dataProvider() - { - $data = array('foo', 'bar'); - $filter = function ($value) { - return 'bar' === $value; - }; - $map = 'strtoupper'; - - return array( - array( - $data, - null, - null, - array('foo', 'bar') - ), - array( - $data, - $filter, - null, - array(1 => 'bar') - ), - array( - $data, - null, - $map, - array('FOO', 'BAR') - ), - array( - $data, - $filter, - $map, - array(1 => 'BAR') - ), - ); - } - -} diff --git a/tests/TestIterableReduce.php b/tests/TestIterableReduce.php deleted file mode 100644 index 99b7709..0000000 --- a/tests/TestIterableReduce.php +++ /dev/null @@ -1,24 +0,0 @@ -assertTrue(function_exists('iterable_to_array')); - } - - public function testIteratorToArray() - { - $iterator = new ArrayIterator(array('foo', 'bar')); - $this->assertEquals(array('foo', 'bar'), iterable_to_array($iterator)); - } - - public function testIteratorWithoutKeysToArray() - { - $iterator = new ArrayIterator(array(1 => 'foo', 2 => 'bar')); - $this->assertEquals(array(0 => 'foo', 1 => 'bar'), iterable_to_array($iterator, false)); - } - - public function testArrayToArray() - { - $array = array('foo', 'bar'); - $this->assertEquals(array('foo', 'bar'), iterable_to_array($array)); - } - - public function testArrayWithoutKeysToArray() - { - $array = array(1 => 'foo', 2 => 'bar'); - $this->assertEquals(array(0 => 'foo', 1 => 'bar'), iterable_to_array($array, false)); - } - - public function testScalarToArray() - { - $scalar = 'foobar'; - $this->assertTrue($this->triggersError($scalar)); - } - - public function testObjectToArray() - { - $object = new stdClass(); - $this->assertTrue($this->triggersError($object)); - } - - public function testResourceToArray() - { - $resource = fopen('php://temp', 'rb'); - $this->assertTrue($this->triggersError($resource)); - } - - private function triggersError($input) - { - return version_compare(PHP_VERSION, '7.0.0') >= 0 ? $this->triggersErrorPHP7($input) : $this->triggersErrorPHP5($input); - } - - private function triggersErrorPHP7($input) - { - $errorOccured = false; - - try { - iterable_to_array($input); - } - catch (\TypeError $e) { - $errorOccured = true; - } - - return $errorOccured; - } - - private function triggersErrorPHP5($input) - { - $errorOccured = false; - - set_error_handler(function ($errno) { - return E_RECOVERABLE_ERROR === $errno; - }); - - if (false === @iterable_to_array($input)) { - $errorOccured = true; - } - - restore_error_handler(); - - return $errorOccured; - } - -} diff --git a/tests/TestIterableToTraversable.php b/tests/TestIterableToTraversable.php deleted file mode 100644 index 2038065..0000000 --- a/tests/TestIterableToTraversable.php +++ /dev/null @@ -1,38 +0,0 @@ -assertTrue(function_exists('iterable_to_traversable')); - } - - public function testIteratorToTraversable() - { - $iterator = new ArrayIterator(array('foo' => 'bar')); - $traversable = iterable_to_traversable($iterator); - $this->assertSame($iterator, $traversable); - $this->assertInstanceOf('Traversable', $iterator); - } - - public function testArrayToTraversable() - { - $array = array('foo' => 'bar'); - $traversable = iterable_to_traversable($array); - $this->assertEquals(new ArrayIterator(array('foo' => 'bar')), $traversable); - $this->assertInstanceOf('Traversable', $traversable); - } - - /** - * @expectedException InvalidArgumentException - */ - public function testInvalidArgument() - { - $string = 'foo'; - iterable_to_traversable($string); - var_dump(iterable_to_traversable(array('foo', 'bar'))); - } -} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..b0ccdc7 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,7 @@ +