From a535593ad11521210dc4f60bd3a1c63ccdee1b01 Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Fri, 13 Dec 2024 01:22:31 +0800 Subject: [PATCH] Implement Nexus Collection --- .php-cs-fixer.dist.php | 4 +- composer.json | 1 + infection.json5 | 19 +- phpstan-baseline.php | 6 + src/Nexus/Collection/Collection.php | 688 ++++++++++++++++++ src/Nexus/Collection/CollectionInterface.php | 101 +++ .../Iterator/ClosureIteratorAggregate.php | 56 ++ .../Iterator/RewindableIterator.php | 66 ++ src/Nexus/Collection/LICENSE | 21 + src/Nexus/Collection/Operation/All.php | 36 + src/Nexus/Collection/Operation/Any.php | 33 + src/Nexus/Collection/Operation/Append.php | 35 + src/Nexus/Collection/Operation/Associate.php | 40 + src/Nexus/Collection/Operation/Chunk.php | 37 + src/Nexus/Collection/Operation/Cycle.php | 35 + src/Nexus/Collection/Operation/Diff.php | 33 + src/Nexus/Collection/Operation/DiffKey.php | 33 + src/Nexus/Collection/Operation/Drop.php | 33 + src/Nexus/Collection/Operation/Every.php | 31 + src/Nexus/Collection/Operation/Filter.php | 34 + src/Nexus/Collection/Operation/FilterKeys.php | 34 + .../Collection/Operation/FilterWithKey.php | 34 + src/Nexus/Collection/Operation/First.php | 35 + src/Nexus/Collection/Operation/Flip.php | 30 + src/Nexus/Collection/Operation/Forget.php | 33 + src/Nexus/Collection/Operation/Get.php | 34 + src/Nexus/Collection/Operation/Has.php | 28 + src/Nexus/Collection/Operation/Intersect.php | 32 + .../Collection/Operation/IntersectKey.php | 33 + src/Nexus/Collection/Operation/Keys.php | 31 + src/Nexus/Collection/Operation/Limit.php | 33 + src/Nexus/Collection/Operation/Map.php | 39 + src/Nexus/Collection/Operation/MapKeys.php | 35 + src/Nexus/Collection/Operation/MapWithKey.php | 36 + src/Nexus/Collection/Operation/Partition.php | 35 + src/Nexus/Collection/Operation/Reduce.php | 37 + src/Nexus/Collection/Operation/Reductions.php | 41 ++ src/Nexus/Collection/Operation/Reject.php | 32 + src/Nexus/Collection/Operation/Slice.php | 36 + src/Nexus/Collection/Operation/Take.php | 33 + src/Nexus/Collection/Operation/Tap.php | 36 + src/Nexus/Collection/Operation/Values.php | 30 + src/Nexus/Collection/README.md | 22 + src/Nexus/Collection/composer.json | 35 + .../Collection/AbstractCollectionTestCase.php | 514 +++++++++++++ tests/Collection/CollectionTest.php | 42 ++ .../CollectionTypeInferenceTest.php | 42 ++ .../Iterator/ClosureIteratorAggregateTest.php | 37 + .../Iterator/RewindableIteratorTest.php | 51 ++ tests/Collection/data/collection.php | 61 ++ tests/Collection/data/iterator.php | 54 ++ tools/build-infection.php | 2 +- tools/src/InfectionConfigBuilder.php | 16 +- 53 files changed, 2959 insertions(+), 6 deletions(-) create mode 100644 src/Nexus/Collection/Collection.php create mode 100644 src/Nexus/Collection/CollectionInterface.php create mode 100644 src/Nexus/Collection/Iterator/ClosureIteratorAggregate.php create mode 100644 src/Nexus/Collection/Iterator/RewindableIterator.php create mode 100644 src/Nexus/Collection/LICENSE create mode 100644 src/Nexus/Collection/Operation/All.php create mode 100644 src/Nexus/Collection/Operation/Any.php create mode 100644 src/Nexus/Collection/Operation/Append.php create mode 100644 src/Nexus/Collection/Operation/Associate.php create mode 100644 src/Nexus/Collection/Operation/Chunk.php create mode 100644 src/Nexus/Collection/Operation/Cycle.php create mode 100644 src/Nexus/Collection/Operation/Diff.php create mode 100644 src/Nexus/Collection/Operation/DiffKey.php create mode 100644 src/Nexus/Collection/Operation/Drop.php create mode 100644 src/Nexus/Collection/Operation/Every.php create mode 100644 src/Nexus/Collection/Operation/Filter.php create mode 100644 src/Nexus/Collection/Operation/FilterKeys.php create mode 100644 src/Nexus/Collection/Operation/FilterWithKey.php create mode 100644 src/Nexus/Collection/Operation/First.php create mode 100644 src/Nexus/Collection/Operation/Flip.php create mode 100644 src/Nexus/Collection/Operation/Forget.php create mode 100644 src/Nexus/Collection/Operation/Get.php create mode 100644 src/Nexus/Collection/Operation/Has.php create mode 100644 src/Nexus/Collection/Operation/Intersect.php create mode 100644 src/Nexus/Collection/Operation/IntersectKey.php create mode 100644 src/Nexus/Collection/Operation/Keys.php create mode 100644 src/Nexus/Collection/Operation/Limit.php create mode 100644 src/Nexus/Collection/Operation/Map.php create mode 100644 src/Nexus/Collection/Operation/MapKeys.php create mode 100644 src/Nexus/Collection/Operation/MapWithKey.php create mode 100644 src/Nexus/Collection/Operation/Partition.php create mode 100644 src/Nexus/Collection/Operation/Reduce.php create mode 100644 src/Nexus/Collection/Operation/Reductions.php create mode 100644 src/Nexus/Collection/Operation/Reject.php create mode 100644 src/Nexus/Collection/Operation/Slice.php create mode 100644 src/Nexus/Collection/Operation/Take.php create mode 100644 src/Nexus/Collection/Operation/Tap.php create mode 100644 src/Nexus/Collection/Operation/Values.php create mode 100644 src/Nexus/Collection/README.md create mode 100644 src/Nexus/Collection/composer.json create mode 100644 tests/Collection/AbstractCollectionTestCase.php create mode 100644 tests/Collection/CollectionTest.php create mode 100644 tests/Collection/CollectionTypeInferenceTest.php create mode 100644 tests/Collection/Iterator/ClosureIteratorAggregateTest.php create mode 100644 tests/Collection/Iterator/RewindableIteratorTest.php create mode 100644 tests/Collection/data/collection.php create mode 100644 tests/Collection/data/iterator.php diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index 95dfb68..d24e531 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -46,7 +46,9 @@ ]) ; -$overrides = []; +$overrides = [ + 'final_public_method_for_abstract_class' => false, +]; $options = [ 'cacheFile' => 'build/.php-cs-fixer.cache', diff --git a/composer.json b/composer.json index f1e2dbe..92cac5c 100644 --- a/composer.json +++ b/composer.json @@ -32,6 +32,7 @@ }, "replace": { "nexusphp/clock": "self.version", + "nexusphp/collection": "self.version", "nexusphp/option": "self.version", "nexusphp/phpstan-nexus": "self.version" }, diff --git a/infection.json5 b/infection.json5 index 95ba64d..1599bbe 100644 --- a/infection.json5 +++ b/infection.json5 @@ -33,7 +33,12 @@ "BitwiseXor": true, "Break_": true, "CastArray": true, - "CastBool": true, + "CastBool": { + "ignore": [ + "Nexus\\Collection\\Collection::filterWithKey", + "Nexus\\Collection\\Collection::reject" + ] + }, "CastFloat": true, "CastInt": { "ignore": [ @@ -41,7 +46,11 @@ ] }, "CastObject": true, - "CastString": true, + "CastString": { + "ignore": [ + "Nexus\\Collection\\Collection::toArrayKey" + ] + }, "CatchBlockRemoval": true, "Catch_": true, "CloneRemoval": true, @@ -122,7 +131,11 @@ "Ternary": true, "This": true, "Throw_": true, - "TrueValue": true, + "TrueValue": { + "ignore": [ + "Nexus\\Collection\\Collection::generateDiffHashTable" + ] + }, "UnwrapArrayChangeKeyCase": true, "UnwrapArrayChunk": true, "UnwrapArrayColumn": true, diff --git a/phpstan-baseline.php b/phpstan-baseline.php index 46aef6f..c8d4cda 100644 --- a/phpstan-baseline.php +++ b/phpstan-baseline.php @@ -1,6 +1,12 @@ '#^Method Nexus\\\\Collection\\\\Collection\\:\\:all\\(\\) should return array\\ but returns array\\\\.$#', + 'identifier' => 'return.type', + 'count' => 1, + 'path' => __DIR__ . '/src/Nexus/Collection/Collection.php', +]; $ignoreErrors[] = [ 'message' => '#^Method Nexus\\\\Option\\\\Choice\\:\\:from\\(\\) never returns Nexus\\\\Option\\\\Some\\ so it can be removed from the return type\\.$#', 'identifier' => 'return.unusedType', diff --git a/src/Nexus/Collection/Collection.php b/src/Nexus/Collection/Collection.php new file mode 100644 index 0000000..5396123 --- /dev/null +++ b/src/Nexus/Collection/Collection.php @@ -0,0 +1,688 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Collection; + +use Nexus\Collection\Iterator\ClosureIteratorAggregate; +use Nexus\Collection\Iterator\RewindableIterator; + +/** + * @template TKey + * @template T + * + * @implements CollectionInterface + * + * @immutable + */ +final class Collection implements CollectionInterface +{ + /** + * @var ClosureIteratorAggregate + */ + private ClosureIteratorAggregate $innerIterator; + + /** + * @template S + * + * @param (\Closure(S): \Iterator) $callable + * @param iterable $parameter + */ + public function __construct(\Closure $callable, iterable $parameter = []) + { + $this->innerIterator = ClosureIteratorAggregate::from($callable, ...$parameter); + } + + /** + * @template WrapKey + * @template Wrap + * + * @return self + */ + public static function wrap(\Closure|iterable $items): self + { + if ($items instanceof \Closure) { + return new self(static fn(): iterable => yield from $items()); + } + + return new self(static fn(): iterable => yield from $items); + } + + /** + * @return ($preserveKeys is false ? list : array) + */ + public function all(bool $preserveKeys = false): array + { + return iterator_to_array($this, $preserveKeys); + } + + public function any(\Closure $predicate): bool + { + foreach ($this as $key => $item) { + if ($predicate($item, $key)) { + return true; + } + } + + return false; + } + + /** + * @template U + * + * @param U ...$items + * + * @return self + */ + public function append(mixed ...$items): self + { + return new self( + static function (iterable $collection) use ($items): iterable { + $iterator = new \AppendIterator(); + + foreach ([$collection, $items] as $iterable) { + $iterator->append( + new \NoRewindIterator( + (static fn(): \Generator => yield from $iterable)(), + ), + ); + } + + yield from $iterator; + }, + [$this], + ); + } + + /** + * @template U + * + * @return self + */ + public function associate(iterable $values): self + { + $valuesIterator = (static fn(): \Generator => yield from $values)(); + + return new self( + static function (iterable $collection) use ($valuesIterator): iterable { + foreach ($collection->values() as $key) { + if (! $valuesIterator->valid()) { + throw new \InvalidArgumentException('The number of values is lesser than the keys.'); + } + + yield $key => $valuesIterator->current(); + + $valuesIterator->next(); + } + + if ($valuesIterator->valid()) { + throw new \InvalidArgumentException('The number of values is greater than the keys.'); + } + }, + [$this], + ); + } + + /** + * @return self> + */ + public function chunk(int $size): self + { + return new self( + static function (iterable $collection) use ($size): \Generator { + $chunk = []; + $count = 0; + + foreach ($collection as $key => $item) { + $chunk[$key] = $item; + ++$count; + + if ($count === $size) { + yield $chunk; + + $chunk = []; + $count = 0; + } + } + + if ([] !== $chunk) { + yield $chunk; + } + }, + [$this], + ); + } + + public function count(): int + { + return iterator_count($this); + } + + /** + * @return self + */ + public function cycle(): self + { + return new self( + static fn(iterable $collection): iterable => new \InfiniteIterator( + new RewindableIterator( + static fn(): \Generator => yield from $collection, + ), + ), + [$this], + ); + } + + /** + * @return self + */ + public function diff(iterable ...$others): self + { + return new self( + static function (iterable $collection) use ($others): iterable { + $hashTable = self::generateDiffHashTable($others); + + foreach ($collection as $key => $value) { + if (! \array_key_exists(self::toArrayKey($value), $hashTable)) { + yield $key => $value; + } + } + }, + [$this], + ); + } + + /** + * @return self + */ + public function diffKey(iterable ...$others): self + { + return new self( + static function (iterable $collection) use ($others): iterable { + $hashTable = self::generateDiffHashTable($others); + + foreach ($collection as $key => $value) { + if (! \array_key_exists(self::toArrayKey($key), $hashTable)) { + yield $key => $value; + } + } + }, + [$this], + ); + } + + /** + * @return self + */ + public function drop(int $length): self + { + return $this->slice($length); + } + + public function every(\Closure $predicate): bool + { + foreach ($this as $key => $item) { + if (! $predicate($item, $key)) { + return false; + } + } + + return true; + } + + /** + * @return self + */ + public function filter(?\Closure $predicate = null): self + { + $predicate ??= static fn(mixed $item): bool => (bool) $item; + + return new self( + static function (iterable $collection) use ($predicate): iterable { + foreach ($collection as $key => $item) { + if ($predicate($item)) { + yield $key => $item; + } + } + }, + [$this], + ); + } + + /** + * @return self + */ + public function filterKeys(?\Closure $predicate = null): self + { + $predicate ??= static fn(mixed $key): bool => (bool) $key; + + return new self( + static function (iterable $collection) use ($predicate): iterable { + foreach ($collection as $key => $item) { + if ($predicate($key)) { + yield $key => $item; + } + } + }, + [$this], + ); + } + + /** + * @return self + */ + public function filterWithKey(?\Closure $predicate = null): self + { + $predicate ??= static fn(mixed $item, mixed $key): bool => (bool) $item && (bool) $key; + + return new self( + static function (iterable $collection) use ($predicate): iterable { + foreach ($collection as $key => $item) { + if ($predicate($item, $key)) { + yield $key => $item; + } + } + }, + [$this], + ); + } + + public function first(\Closure $predicate, mixed $default = null): mixed + { + return $this + ->filterWithKey($predicate) + ->append($default) + ->limit(1) + ->getIterator() + ->current() + ; + } + + /** + * @return self + */ + public function flip(): self + { + return new self( + static function (iterable $collection): iterable { + foreach ($collection as $key => $item) { + yield $item => $key; + } + }, + [$this], + ); + } + + /** + * @return self + */ + public function forget(mixed ...$keys): self + { + return $this->filterKeys( + static fn(mixed $key): bool => ! \in_array($key, $keys, true), + ); + } + + public function get(mixed $key, mixed $default = null): mixed + { + return $this + ->filterKeys(static fn(mixed $k): bool => $key === $k) + ->append($default) + ->limit(1) + ->getIterator() + ->current() + ; + } + + /** + * @return \Generator + */ + public function getIterator(): \Traversable + { + yield from $this->innerIterator->getIterator(); + } + + public function has(mixed $key): bool + { + return $this + ->filterKeys(static fn(mixed $k): bool => $key === $k) + ->limit(1) + ->getIterator() + ->valid() + ; + } + + /** + * @return self + */ + public function intersect(iterable ...$others): self + { + return new self( + static function (iterable $collection) use ($others): iterable { + $hashTable = self::generateIntersectHashTable($others); + $count = \count($others); + + foreach ($collection as $key => $value) { + $encodedValue = self::toArrayKey($value); + + if ( + \array_key_exists($encodedValue, $hashTable) + && $hashTable[$encodedValue] === $count + ) { + yield $key => $value; + } + } + }, + [$this], + ); + } + + /** + * @return self + */ + public function intersectKey(iterable ...$others): self + { + return new self( + static function (iterable $collection) use ($others): iterable { + $hashTable = self::generateIntersectHashTable($others); + $count = \count($others); + + foreach ($collection as $key => $value) { + $encodedKey = self::toArrayKey($key); + + if ( + \array_key_exists($encodedKey, $hashTable) + && $hashTable[$encodedKey] === $count + ) { + yield $key => $value; + } + } + }, + [$this], + ); + } + + /** + * @return self + */ + public function keys(): self + { + return new self( + static function (iterable $collection): iterable { + foreach ($collection as $key => $_) { + yield $key; + } + }, + [$this], + ); + } + + /** + * @return self + */ + public function limit(int $limit = -1, int $offset = 0): self + { + return new self( + static fn(iterable $collection): iterable => yield from new \LimitIterator( + (static fn(): iterable => yield from $collection)(), + $offset, + $limit, + ), + [$this], + ); + } + + /** + * @template U + * + * @return self + */ + public function map(\Closure $predicate): self + { + return new self( + static function (iterable $collection) use ($predicate): iterable { + foreach ($collection as $key => $item) { + yield $key => $predicate($item); + } + }, + [$this], + ); + } + + /** + * @template UKey + * + * @return self + */ + public function mapKeys(\Closure $predicate): self + { + return new self( + static function (iterable $collection) use ($predicate): iterable { + foreach ($collection as $key => $item) { + yield $predicate($key) => $item; + } + }, + [$this], + ); + } + + /** + * @template U + * + * @return self + */ + public function mapWithKey(\Closure $predicate): self + { + return new self( + static function (iterable $collection) use ($predicate): iterable { + foreach ($collection as $key => $item) { + yield $key => $predicate($item, $key); + } + }, + [$this], + ); + } + + /** + * @return self> + */ + public function partition(\Closure $predicate): self + { + return new self( + static function (iterable $collection) use ($predicate): iterable { + yield $collection->filterWithKey($predicate); + + yield $collection->reject($predicate); + }, + [$this], + ); + } + + public function reduce(\Closure $predicate, mixed $initial = null): mixed + { + $accumulator = $initial; + + foreach ($this as $key => $item) { + $accumulator = $predicate($accumulator, $item, $key); + } + + return $accumulator; + } + + /** + * @template TAcc + * + * @return self + */ + public function reductions(\Closure $predicate, mixed $initial = null): self + { + return new self( + static function (iterable $collection) use ($predicate, $initial): iterable { + $accumulator = $initial; + + foreach ($collection as $key => $item) { + $accumulator = $predicate($accumulator, $item, $key); + + yield $accumulator; + } + }, + [$this], + ); + } + + /** + * @return self + */ + public function reject(?\Closure $predicate = null): self + { + $predicate ??= static fn(mixed $item, mixed $key): bool => (bool) $item && (bool) $key; + + return new self( + static function (iterable $collection) use ($predicate): iterable { + foreach ($collection as $key => $item) { + if (! $predicate($item, $key)) { + yield $key => $item; + } + } + }, + [$this], + ); + } + + /** + * @return self + */ + public function slice(int $start, ?int $length = null): self + { + return new self( + static function (iterable $collection) use ($start, $length): iterable { + if (0 === $length) { + yield from $collection; + + return; + } + + $i = 0; + + foreach ($collection as $key => $item) { + if ($i++ < $start) { + continue; + } + + yield $key => $item; + + if (null !== $length && $i >= $start + $length) { + break; + } + } + }, + [$this], + ); + } + + /** + * @return self + */ + public function take(int $length): self + { + return $this->slice(0, $length); + } + + /** + * @return self + */ + public function tap(\Closure ...$callbacks): self + { + return new self( + static function (iterable $collection) use ($callbacks): iterable { + foreach ($collection as $key => $item) { + foreach ($callbacks as $callback) { + $callback($item, $key); + } + } + + yield from $collection; + }, + [$this], + ); + } + + /** + * @return self + */ + public function values(): self + { + return new self( + static function (iterable $collection): iterable { + foreach ($collection as $item) { + yield $item; + } + }, + [$this], + ); + } + + /** + * Generates a hash table for lookup by `diff` and `diffKey`. + * + * @param array> $iterables + * + * @return array + */ + private static function generateDiffHashTable(array $iterables): array + { + $hashTable = []; + + foreach ($iterables as $iterable) { + foreach ($iterable as $value) { + $hashTable[self::toArrayKey($value)] = true; + } + } + + return $hashTable; + } + + /** + * Generates a hash table for lookup by `intersect` and `intersectKey`. + * + * @param array> $iterables + * + * @return array> + */ + private static function generateIntersectHashTable(array $iterables): array + { + $hashTable = []; + + foreach ($iterables as $iterable) { + foreach ($iterable as $value) { + $encodedValue = self::toArrayKey($value); + + if (! \array_key_exists($encodedValue, $hashTable)) { + $hashTable[$encodedValue] = 1; + } else { + ++$hashTable[$encodedValue]; + } + } + } + + return $hashTable; + } + + private static function toArrayKey(mixed $input): string + { + if (\is_string($input)) { + return $input; + } + + return (string) json_encode($input); + } +} diff --git a/src/Nexus/Collection/CollectionInterface.php b/src/Nexus/Collection/CollectionInterface.php new file mode 100644 index 0000000..7a4f098 --- /dev/null +++ b/src/Nexus/Collection/CollectionInterface.php @@ -0,0 +1,101 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Collection; + +/** + * @template TKey + * @template T + * + * @extends \IteratorAggregate + * @extends Operation\All + * @extends Operation\Any + * @extends Operation\Append + * @extends Operation\Associate + * @extends Operation\Chunk + * @extends Operation\Cycle + * @extends Operation\Diff + * @extends Operation\DiffKey + * @extends Operation\Drop + * @extends Operation\Every + * @extends Operation\Filter + * @extends Operation\FilterKeys + * @extends Operation\FilterWithKey + * @extends Operation\First + * @extends Operation\Flip + * @extends Operation\Forget + * @extends Operation\Get + * @extends Operation\Has + * @extends Operation\Intersect + * @extends Operation\IntersectKey + * @extends Operation\Keys + * @extends Operation\Limit + * @extends Operation\Map + * @extends Operation\MapKeys + * @extends Operation\MapWithKey + * @extends Operation\Partition + * @extends Operation\Reduce + * @extends Operation\Reductions + * @extends Operation\Reject + * @extends Operation\Slice + * @extends Operation\Take + * @extends Operation\Tap + * @extends Operation\Values + */ +interface CollectionInterface extends + \Countable, + \IteratorAggregate, + Operation\All, + Operation\Any, + Operation\Append, + Operation\Associate, + Operation\Chunk, + Operation\Cycle, + Operation\Diff, + Operation\DiffKey, + Operation\Drop, + Operation\Every, + Operation\Filter, + Operation\FilterKeys, + Operation\FilterWithKey, + Operation\First, + Operation\Flip, + Operation\Forget, + Operation\Get, + Operation\Has, + Operation\Intersect, + Operation\IntersectKey, + Operation\Keys, + Operation\Limit, + Operation\Map, + Operation\MapKeys, + Operation\MapWithKey, + Operation\Partition, + Operation\Reduce, + Operation\Reductions, + Operation\Reject, + Operation\Slice, + Operation\Take, + Operation\Tap, + Operation\Values +{ + /** + * @template WrapKey + * @template Wrap + * + * @param (\Closure(): iterable)|iterable $items + * + * @return self + */ + public static function wrap(\Closure|iterable $items): self; +} diff --git a/src/Nexus/Collection/Iterator/ClosureIteratorAggregate.php b/src/Nexus/Collection/Iterator/ClosureIteratorAggregate.php new file mode 100644 index 0000000..64aff0c --- /dev/null +++ b/src/Nexus/Collection/Iterator/ClosureIteratorAggregate.php @@ -0,0 +1,56 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Collection\Iterator; + +/** + * @template TKey + * @template T + * + * @implements \IteratorAggregate + * + * @internal + */ +final class ClosureIteratorAggregate implements \IteratorAggregate +{ + /** + * @template S + * + * @param (\Closure(S): iterable) $callable + * @param S $parameter + */ + private function __construct( + private \Closure $callable, + private mixed $parameter, + ) {} + + /** + * @template VKey + * @template V + * @template U + * + * @param (\Closure(U): iterable) $callable + * @param U $parameter + * + * @return self + */ + public static function from(\Closure $callable, mixed $parameter = null): self + { + return new self($callable, $parameter); + } + + public function getIterator(): \Generator + { + yield from ($this->callable)($this->parameter); + } +} diff --git a/src/Nexus/Collection/Iterator/RewindableIterator.php b/src/Nexus/Collection/Iterator/RewindableIterator.php new file mode 100644 index 0000000..327039b --- /dev/null +++ b/src/Nexus/Collection/Iterator/RewindableIterator.php @@ -0,0 +1,66 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Collection\Iterator; + +/** + * @template TKey + * @template T + * + * @implements \Iterator + * + * @see https://github.com/nikic/iter/blob/master/src/iter.rewindable.php + * + * @internal + */ +final class RewindableIterator implements \Iterator +{ + private \Iterator $iterator; + + /** + * @param (\Closure(mixed...): \Iterator) $callable + * @param list $args + */ + public function __construct( + private \Closure $callable, + private array $args = [], + ) { + $this->rewind(); + } + + public function current(): mixed + { + return $this->iterator->current(); + } + + public function next(): void + { + $this->iterator->next(); + } + + public function key(): mixed + { + return $this->iterator->key(); + } + + public function valid(): bool + { + return $this->iterator->valid(); + } + + public function rewind(): void + { + $callable = $this->callable; + $this->iterator = ($callable)(...$this->args); + } +} diff --git a/src/Nexus/Collection/LICENSE b/src/Nexus/Collection/LICENSE new file mode 100644 index 0000000..ce701d4 --- /dev/null +++ b/src/Nexus/Collection/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 John Paul E. Balandan, CPA + +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. diff --git a/src/Nexus/Collection/Operation/All.php b/src/Nexus/Collection/Operation/All.php new file mode 100644 index 0000000..c5840dc --- /dev/null +++ b/src/Nexus/Collection/Operation/All.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Collection\Operation; + +/** + * @template TKey + * @template T + */ +interface All +{ + /** + * Turns a collection into its array representation. + * + * Care should be taken when using this method when the collection has potential + * duplicate keys. In case keys are preserved, the last of the duplicate keys + * will be retained and the previous keys will be lost. + * + * @param bool $preserveKeys Whether keys will be preserved during conversion. + * Set this to `false` to prevent data loss when dealing + * with duplicate keys. + * + * @return ($preserveKeys is false ? list : array) + */ + public function all(bool $preserveKeys = false): array; +} diff --git a/src/Nexus/Collection/Operation/Any.php b/src/Nexus/Collection/Operation/Any.php new file mode 100644 index 0000000..91f6d9b --- /dev/null +++ b/src/Nexus/Collection/Operation/Any.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Collection\Operation; + +/** + * @template TKey + * @template T + */ +interface Any +{ + /** + * Returns `true` if any of the values in the collection + * satisfies the `$predicate`. + * + * This method is short-circuiting, i.e., once the predicate + * matches an item the remaining items will not be considered + * anymore. + * + * @param (\Closure(T, TKey): bool) $predicate + */ + public function any(\Closure $predicate): bool; +} diff --git a/src/Nexus/Collection/Operation/Append.php b/src/Nexus/Collection/Operation/Append.php new file mode 100644 index 0000000..39fd8fa --- /dev/null +++ b/src/Nexus/Collection/Operation/Append.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Collection\Operation; + +use Nexus\Collection\CollectionInterface; + +/** + * @template TKey + * @template T + */ +interface Append +{ + /** + * Appends one or more `$items` into the collection and returns + * a new collection. + * + * @template U + * + * @param U ...$items + * + * @return CollectionInterface + */ + public function append(mixed ...$items): CollectionInterface; +} diff --git a/src/Nexus/Collection/Operation/Associate.php b/src/Nexus/Collection/Operation/Associate.php new file mode 100644 index 0000000..08f4efe --- /dev/null +++ b/src/Nexus/Collection/Operation/Associate.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Collection\Operation; + +use Nexus\Collection\CollectionInterface; + +/** + * @template TKey + * @template T + */ +interface Associate +{ + /** + * Combines the collection to another iterable. + * + * The values of the collection will be the keys of the new collection + * while the values of the other will be the values. + * + * @template UKey + * @template U + * + * @param iterable $values + * + * @return CollectionInterface + * + * @throws \InvalidArgumentException + */ + public function associate(iterable $values): CollectionInterface; +} diff --git a/src/Nexus/Collection/Operation/Chunk.php b/src/Nexus/Collection/Operation/Chunk.php new file mode 100644 index 0000000..fee50a5 --- /dev/null +++ b/src/Nexus/Collection/Operation/Chunk.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Collection\Operation; + +use Nexus\Collection\CollectionInterface; + +/** + * @template TKey + * @template T + */ +interface Chunk +{ + /** + * Returns a new collection containing the original items in the + * collection split into chunks of given `$size`. + * + * This chunking operation preserves the keys. If the original + * collection does not split evenly, the final chunk will be + * smaller. + * + * @param int<1, max> $size + * + * @return CollectionInterface> + */ + public function chunk(int $size): CollectionInterface; +} diff --git a/src/Nexus/Collection/Operation/Cycle.php b/src/Nexus/Collection/Operation/Cycle.php new file mode 100644 index 0000000..b52a23f --- /dev/null +++ b/src/Nexus/Collection/Operation/Cycle.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Collection\Operation; + +use Nexus\Collection\CollectionInterface; + +/** + * @template TKey + * @template T + */ +interface Cycle +{ + /** + * Cycle indefinitely over a collection of items. + * + * **NOTE:** Be careful when using `all()` after calling `cycle()` as this + * will exhaust all available memory trying to convert an infinite + * collection. Make sure to call `limit()` after `cycle()` before + * outputting the items. + * + * @return CollectionInterface + */ + public function cycle(): CollectionInterface; +} diff --git a/src/Nexus/Collection/Operation/Diff.php b/src/Nexus/Collection/Operation/Diff.php new file mode 100644 index 0000000..a191942 --- /dev/null +++ b/src/Nexus/Collection/Operation/Diff.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Collection\Operation; + +use Nexus\Collection\CollectionInterface; + +/** + * @template TKey + * @template T + */ +interface Diff +{ + /** + * Computes the difference of the collection against a series of other + * iterables (i.e., collections, arrays, Traversable objects). + * + * @param iterable ...$others + * + * @return CollectionInterface + */ + public function diff(iterable ...$others): CollectionInterface; +} diff --git a/src/Nexus/Collection/Operation/DiffKey.php b/src/Nexus/Collection/Operation/DiffKey.php new file mode 100644 index 0000000..e1e36c8 --- /dev/null +++ b/src/Nexus/Collection/Operation/DiffKey.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Collection\Operation; + +use Nexus\Collection\CollectionInterface; + +/** + * @template TKey + * @template T + */ +interface DiffKey +{ + /** + * Computes the difference of the collection against other iterables + * using the keys for comparison. + * + * @param iterable ...$others + * + * @return CollectionInterface + */ + public function diffKey(iterable ...$others): CollectionInterface; +} diff --git a/src/Nexus/Collection/Operation/Drop.php b/src/Nexus/Collection/Operation/Drop.php new file mode 100644 index 0000000..a44f690 --- /dev/null +++ b/src/Nexus/Collection/Operation/Drop.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Collection\Operation; + +use Nexus\Collection\CollectionInterface; + +/** + * @template TKey + * @template T + */ +interface Drop +{ + /** + * Drops the first items with `$length` from the collection + * and returns a new collection on the remaining items. + * + * @param int<0, max> $length Number of items to drop from the start + * + * @return CollectionInterface + */ + public function drop(int $length): CollectionInterface; +} diff --git a/src/Nexus/Collection/Operation/Every.php b/src/Nexus/Collection/Operation/Every.php new file mode 100644 index 0000000..f96c169 --- /dev/null +++ b/src/Nexus/Collection/Operation/Every.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Collection\Operation; + +/** + * @template TKey + * @template T + */ +interface Every +{ + /** + * Returns `true` if all values in the collection satisfy the `$predicate`. + * + * This method is short-circuiting, i.e., if the predicate fails for the + * first time, all remaining items will no longer be considered. + * + * @param (\Closure(T, TKey): bool) $predicate + */ + public function every(\Closure $predicate): bool; +} diff --git a/src/Nexus/Collection/Operation/Filter.php b/src/Nexus/Collection/Operation/Filter.php new file mode 100644 index 0000000..4260760 --- /dev/null +++ b/src/Nexus/Collection/Operation/Filter.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Collection\Operation; + +use Nexus\Collection\CollectionInterface; + +/** + * @template TKey + * @template T + */ +interface Filter +{ + /** + * Returns a new collection from items where `$predicate` returns `true`. + * + * If no `$predicate` is provided, this will just check for non-falsey values. + * + * @param null|(\Closure(T): bool) $predicate + * + * @return CollectionInterface + */ + public function filter(?\Closure $predicate = null): CollectionInterface; +} diff --git a/src/Nexus/Collection/Operation/FilterKeys.php b/src/Nexus/Collection/Operation/FilterKeys.php new file mode 100644 index 0000000..8192723 --- /dev/null +++ b/src/Nexus/Collection/Operation/FilterKeys.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Collection\Operation; + +use Nexus\Collection\CollectionInterface; + +/** + * @template TKey + * @template T + */ +interface FilterKeys +{ + /** + * Returns a new collection from keys where `$predicate` returns `true`. + * + * If `$predicate` is not provided, this will just check for non-falsey keys. + * + * @param null|(\Closure(TKey): bool) $predicate + * + * @return CollectionInterface + */ + public function filterKeys(?\Closure $predicate = null): CollectionInterface; +} diff --git a/src/Nexus/Collection/Operation/FilterWithKey.php b/src/Nexus/Collection/Operation/FilterWithKey.php new file mode 100644 index 0000000..45feaaf --- /dev/null +++ b/src/Nexus/Collection/Operation/FilterWithKey.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Collection\Operation; + +use Nexus\Collection\CollectionInterface; + +/** + * @template TKey + * @template T + */ +interface FilterWithKey +{ + /** + * Returns a new collection from keys and values where `$predicate` returns `true`. + * + * If `$predicate` is not provided, this just checks for non-falsey keys and values. + * + * @param null|(\Closure(T, TKey): bool) $predicate + * + * @return CollectionInterface + */ + public function filterWithKey(?\Closure $predicate = null): CollectionInterface; +} diff --git a/src/Nexus/Collection/Operation/First.php b/src/Nexus/Collection/Operation/First.php new file mode 100644 index 0000000..92705aa --- /dev/null +++ b/src/Nexus/Collection/Operation/First.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Collection\Operation; + +/** + * @template TKey + * @template T + */ +interface First +{ + /** + * Gets the first item in the collection that passes the given `$predicate`. + * + * If none was found, this returns the `$default` value. + * + * @template V + * + * @param (\Closure(T, TKey): bool) $predicate + * @param V $default + * + * @return T|V + */ + public function first(\Closure $predicate, mixed $default = null): mixed; +} diff --git a/src/Nexus/Collection/Operation/Flip.php b/src/Nexus/Collection/Operation/Flip.php new file mode 100644 index 0000000..0377cfb --- /dev/null +++ b/src/Nexus/Collection/Operation/Flip.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Collection\Operation; + +use Nexus\Collection\CollectionInterface; + +/** + * @template TKey + * @template T + */ +interface Flip +{ + /** + * Flips the keys and values and returns them as a new collection. + * + * @return CollectionInterface + */ + public function flip(): CollectionInterface; +} diff --git a/src/Nexus/Collection/Operation/Forget.php b/src/Nexus/Collection/Operation/Forget.php new file mode 100644 index 0000000..836062b --- /dev/null +++ b/src/Nexus/Collection/Operation/Forget.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Collection\Operation; + +use Nexus\Collection\CollectionInterface; + +/** + * @template TKey + * @template T + */ +interface Forget +{ + /** + * Removes items having the specified keys, then returns the remaining + * items as a new collection. + * + * @param TKey ...$keys + * + * @return CollectionInterface + */ + public function forget(mixed ...$keys): CollectionInterface; +} diff --git a/src/Nexus/Collection/Operation/Get.php b/src/Nexus/Collection/Operation/Get.php new file mode 100644 index 0000000..5623e4e --- /dev/null +++ b/src/Nexus/Collection/Operation/Get.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Collection\Operation; + +/** + * @template TKey + * @template T + */ +interface Get +{ + /** + * Gets the specific item from the collection having the specified key. + * If none exists, returns the default value. + * + * @template V + * + * @param TKey $key + * @param V $default + * + * @return T|V + */ + public function get(mixed $key, mixed $default = null): mixed; +} diff --git a/src/Nexus/Collection/Operation/Has.php b/src/Nexus/Collection/Operation/Has.php new file mode 100644 index 0000000..7af4691 --- /dev/null +++ b/src/Nexus/Collection/Operation/Has.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Collection\Operation; + +/** + * @template TKey + * @template T + */ +interface Has +{ + /** + * Checks if the collection has an item with the specified key. + * + * @param TKey $key + */ + public function has(mixed $key): bool; +} diff --git a/src/Nexus/Collection/Operation/Intersect.php b/src/Nexus/Collection/Operation/Intersect.php new file mode 100644 index 0000000..ccf0312 --- /dev/null +++ b/src/Nexus/Collection/Operation/Intersect.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Collection\Operation; + +use Nexus\Collection\CollectionInterface; + +/** + * @template TKey + * @template T + */ +interface Intersect +{ + /** + * Computes the intersection of the collection against other iterables. + * + * @param iterable ...$others + * + * @return CollectionInterface + */ + public function intersect(iterable ...$others): CollectionInterface; +} diff --git a/src/Nexus/Collection/Operation/IntersectKey.php b/src/Nexus/Collection/Operation/IntersectKey.php new file mode 100644 index 0000000..618f555 --- /dev/null +++ b/src/Nexus/Collection/Operation/IntersectKey.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Collection\Operation; + +use Nexus\Collection\CollectionInterface; + +/** + * @template TKey + * @template T + */ +interface IntersectKey +{ + /** + * Computes the intersection of the collection against other iterables + * using keys for comparison. + * + * @param iterable ...$others + * + * @return CollectionInterface + */ + public function intersectKey(iterable ...$others): CollectionInterface; +} diff --git a/src/Nexus/Collection/Operation/Keys.php b/src/Nexus/Collection/Operation/Keys.php new file mode 100644 index 0000000..fe5c9b0 --- /dev/null +++ b/src/Nexus/Collection/Operation/Keys.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Collection\Operation; + +use Nexus\Collection\CollectionInterface; + +/** + * @template TKey + * @template T + */ +interface Keys +{ + /** + * Returns a new collection from keys of the original collection as + * the new values. + * + * @return CollectionInterface + */ + public function keys(): CollectionInterface; +} diff --git a/src/Nexus/Collection/Operation/Limit.php b/src/Nexus/Collection/Operation/Limit.php new file mode 100644 index 0000000..c13672b --- /dev/null +++ b/src/Nexus/Collection/Operation/Limit.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Collection\Operation; + +use Nexus\Collection\CollectionInterface; + +/** + * @template TKey + * @template T + */ +interface Limit +{ + /** + * Limit the number of values in the collection. + * + * @param int<-1, max> $limit + * @param int<0, max> $offset + * + * @return CollectionInterface + */ + public function limit(int $limit = -1, int $offset = 0): CollectionInterface; +} diff --git a/src/Nexus/Collection/Operation/Map.php b/src/Nexus/Collection/Operation/Map.php new file mode 100644 index 0000000..a4539e9 --- /dev/null +++ b/src/Nexus/Collection/Operation/Map.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Collection\Operation; + +use Nexus\Collection\CollectionInterface; + +/** + * @template TKey + * @template T + */ +interface Map +{ + /** + * Returns a new collection consisting of items transformed by applying + * the mapping function `$predicate`. + * + * Only the values are passed to the closure. If you want to pass only + * the keys, you need to use `CollectionInterface::mapKeys()`. If you + * want both keys and values, you need `CollectionInterface::mapWithKey()`. + * + * @template U + * + * @param (\Closure(T): U) $predicate + * + * @return CollectionInterface + */ + public function map(\Closure $predicate): CollectionInterface; +} diff --git a/src/Nexus/Collection/Operation/MapKeys.php b/src/Nexus/Collection/Operation/MapKeys.php new file mode 100644 index 0000000..8ec9dc2 --- /dev/null +++ b/src/Nexus/Collection/Operation/MapKeys.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Collection\Operation; + +use Nexus\Collection\CollectionInterface; + +/** + * @template TKey + * @template T + */ +interface MapKeys +{ + /** + * Returns a new collection consisting of items transformed by the + * mapping function `$predicate` that accepts the keys as inputs. + * + * @template UKey + * + * @param (\Closure(TKey): UKey) $predicate + * + * @return CollectionInterface + */ + public function mapKeys(\Closure $predicate): CollectionInterface; +} diff --git a/src/Nexus/Collection/Operation/MapWithKey.php b/src/Nexus/Collection/Operation/MapWithKey.php new file mode 100644 index 0000000..fbd08dc --- /dev/null +++ b/src/Nexus/Collection/Operation/MapWithKey.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Collection\Operation; + +use Nexus\Collection\CollectionInterface; + +/** + * @template TKey + * @template T + */ +interface MapWithKey +{ + /** + * Returns a new collection consisting of items transformed by the + * mapping function `$predicate` that accepts the keys and values + * as inputs. The keys are left unchanged. + * + * @template U + * + * @param (\Closure(T, TKey): U) $predicate + * + * @return CollectionInterface + */ + public function mapWithKey(\Closure $predicate): CollectionInterface; +} diff --git a/src/Nexus/Collection/Operation/Partition.php b/src/Nexus/Collection/Operation/Partition.php new file mode 100644 index 0000000..0253799 --- /dev/null +++ b/src/Nexus/Collection/Operation/Partition.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Collection\Operation; + +use Nexus\Collection\CollectionInterface; + +/** + * @template TKey + * @template T + */ +interface Partition +{ + /** + * Partition the collection into 2 tuples of collections. + * + * The first inner collection consists of items that have passed the `$predicate`. + * The last inner collection consists of items that have failed the `$predicate`. + * + * @param (\Closure(T, TKey): bool) $predicate + * + * @return CollectionInterface> + */ + public function partition(\Closure $predicate): CollectionInterface; +} diff --git a/src/Nexus/Collection/Operation/Reduce.php b/src/Nexus/Collection/Operation/Reduce.php new file mode 100644 index 0000000..1725c03 --- /dev/null +++ b/src/Nexus/Collection/Operation/Reduce.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Collection\Operation; + +/** + * @template TKey + * @template T + */ +interface Reduce +{ + /** + * Reduce a collection using a `$predicate`. + * + * The reduction function is passed an accumulator value and the current + * iterator value and returns a new accumulator. The accumulator is + * initialised to `$initial`. + * + * @template TAcc + * + * @param (\Closure(TAcc, T, TKey): TAcc) $predicate + * @param TAcc $initial + * + * @return TAcc + */ + public function reduce(\Closure $predicate, mixed $initial = null): mixed; +} diff --git a/src/Nexus/Collection/Operation/Reductions.php b/src/Nexus/Collection/Operation/Reductions.php new file mode 100644 index 0000000..004f70e --- /dev/null +++ b/src/Nexus/Collection/Operation/Reductions.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Collection\Operation; + +use Nexus\Collection\CollectionInterface; + +/** + * @template TKey + * @template T + */ +interface Reductions +{ + /** + * Returns a new collection consisting of the intermediate values + * of reducing the collection using a `$predicate`. + * + * The reduction `$predicate` is passed an `$initial` accumulator + * and the current collection value and returns a new accumulator. + * + * Reductions returns a list of every accumulator along the way. + * + * @template TAcc + * + * @param (\Closure(TAcc, T, TKey): TAcc) $predicate + * @param TAcc $initial + * + * @return CollectionInterface + */ + public function reductions(\Closure $predicate, mixed $initial = null): CollectionInterface; +} diff --git a/src/Nexus/Collection/Operation/Reject.php b/src/Nexus/Collection/Operation/Reject.php new file mode 100644 index 0000000..9bdf53c --- /dev/null +++ b/src/Nexus/Collection/Operation/Reject.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Collection\Operation; + +use Nexus\Collection\CollectionInterface; + +/** + * @template TKey + * @template T + */ +interface Reject +{ + /** + * Returns a new collection from items where `$predicate` returns false. + * + * @param null|(\Closure(T, TKey): bool) $predicate + * + * @return CollectionInterface + */ + public function reject(?\Closure $predicate = null): CollectionInterface; +} diff --git a/src/Nexus/Collection/Operation/Slice.php b/src/Nexus/Collection/Operation/Slice.php new file mode 100644 index 0000000..771b63b --- /dev/null +++ b/src/Nexus/Collection/Operation/Slice.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Collection\Operation; + +use Nexus\Collection\CollectionInterface; + +/** + * @template TKey + * @template T + */ +interface Slice +{ + /** + * Takes a slice from a collection and returns it + * as a new collection. + * + * @param int<0, max> $start Start offset + * @param null|int<0, max> $length Length of collection (if not specified, + * all remaining values from the collection + * are used) + * + * @return CollectionInterface + */ + public function slice(int $start, ?int $length = null): CollectionInterface; +} diff --git a/src/Nexus/Collection/Operation/Take.php b/src/Nexus/Collection/Operation/Take.php new file mode 100644 index 0000000..28eaaef --- /dev/null +++ b/src/Nexus/Collection/Operation/Take.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Collection\Operation; + +use Nexus\Collection\CollectionInterface; + +/** + * @template TKey + * @template T + */ +interface Take +{ + /** + * Takes the first items with `$length` + * and returns them as a new collection. + * + * @param int<0, max> $length Number of items to take from the start + * + * @return CollectionInterface + */ + public function take(int $length): CollectionInterface; +} diff --git a/src/Nexus/Collection/Operation/Tap.php b/src/Nexus/Collection/Operation/Tap.php new file mode 100644 index 0000000..daa5e94 --- /dev/null +++ b/src/Nexus/Collection/Operation/Tap.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Collection\Operation; + +use Nexus\Collection\CollectionInterface; + +/** + * @template TKey + * @template T + */ +interface Tap +{ + /** + * Executes callbacks on each item of the collection. + * + * The passed callbacks are called with the value and key of + * each item in the collection. The return value of the + * callbacks are ignored. + * + * @param (\Closure(T, TKey): void) ...$callbacks + * + * @return CollectionInterface + */ + public function tap(\Closure ...$callbacks): CollectionInterface; +} diff --git a/src/Nexus/Collection/Operation/Values.php b/src/Nexus/Collection/Operation/Values.php new file mode 100644 index 0000000..c6ba0e1 --- /dev/null +++ b/src/Nexus/Collection/Operation/Values.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Collection\Operation; + +use Nexus\Collection\CollectionInterface; + +/** + * @template TKey + * @template T + */ +interface Values +{ + /** + * Returns a new collection of values, ignoring the original keys. + * + * @return CollectionInterface + */ + public function values(): CollectionInterface; +} diff --git a/src/Nexus/Collection/README.md b/src/Nexus/Collection/README.md new file mode 100644 index 0000000..006eb65 --- /dev/null +++ b/src/Nexus/Collection/README.md @@ -0,0 +1,22 @@ +# Nexus Collection + +A lazy and memory-efficient collection library. + +## Installation + + composer require nexusphp/collection + +## Getting Started + +## License + +Nexus Collection is licensed under the [MIT License][1]. + +## Resources + +* [Report issues][2] and [send pull requests][3] in the [main Nexus repository][4] + +[1]: LICENSE +[2]: https://github.com/NexusPHP/framework/issues +[3]: https://github.com/NexusPHP/framework/pulls +[4]: https://github.com/NexusPHP/framework diff --git a/src/Nexus/Collection/composer.json b/src/Nexus/Collection/composer.json new file mode 100644 index 0000000..7728878 --- /dev/null +++ b/src/Nexus/Collection/composer.json @@ -0,0 +1,35 @@ +{ + "name": "nexusphp/collection", + "description": "A lazy and memory-efficient collection library.", + "license": "MIT", + "type": "library", + "keywords": [ + "nexus", + "collection" + ], + "authors": [ + { + "name": "John Paul E. Balandan, CPA", + "email": "paulbalandan@gmail.com" + } + ], + "support": { + "issues": "https://github.com/NexusPHP/framework/issues", + "source": "https://github.com/NexusPHP/framework" + }, + "require": { + "php": "^8.3" + }, + "minimum-stability": "dev", + "prefer-stable": true, + "autoload": { + "psr-4": { + "Nexus\\Collection\\": "" + } + }, + "config": { + "optimize-autoloader": true, + "preferred-install": "dist", + "sort-packages": true + } +} diff --git a/tests/Collection/AbstractCollectionTestCase.php b/tests/Collection/AbstractCollectionTestCase.php new file mode 100644 index 0000000..df3e5a0 --- /dev/null +++ b/tests/Collection/AbstractCollectionTestCase.php @@ -0,0 +1,514 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Tests\Collection; + +use Nexus\Collection\CollectionInterface; +use Nexus\Collection\Iterator\RewindableIterator; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; + +/** + * This test case tests the non-static methods of `CollectionInterface`. + * Static methods should be tested individually by each concrete test case. + * + * @internal + */ +abstract class AbstractCollectionTestCase extends TestCase +{ + public function testAll(): void + { + $collection = $this->collection(static function (): iterable { + yield 'a' => 1; + + yield 2 => 2; + + yield 3 => 3; + + yield null => 4; + + yield true => 5; + + yield false => 6; + }); + + self::assertSame([1, 2, 3, 4, 5, 6], $collection->all()); + self::assertSame(['a' => 1, 2 => 2, 3 => 3, '' => 4, 1 => 5, 0 => 6], $collection->all(true)); + + $this->expectException(\TypeError::class); + $this->expectExceptionMessage('Cannot access offset of type stdClass on array'); + $this->collection(static fn(): iterable => yield new \stdClass() => 5)->all(true); + } + + public function testAny(): void + { + $collection = $this->collection(); + + self::assertTrue($collection->any(static fn(int $v): bool => $v % 2 === 0)); + self::assertFalse($collection->any(static fn(int $v): bool => $v > 5)); + } + + public function testAppend(): void + { + self::assertSame( + [1, 2, 3, 4, 5, 'a', 'b', 'c', 'd', 'e'], + $this->collection()->append('a', 'b', 'c', 'd', 'e')->all(), + ); + self::assertSame( + [1, 2, 3, 4, 1, 2, 3, 4], + $this->collection([1, 2]) + ->append(3, 4) + ->cycle() + ->limit(8) + ->all(), + ); + } + + public function testAssociate(): void + { + $collection = $this->collection(static function (): iterable { + yield 1; + + yield 2; + + yield 3; + }); + + self::assertSame( + [1 => 'a', 2 => 'b', 3 => 'c'], + $collection->associate(['a', 'b', 'c'])->all(true), + ); + } + + /** + * @param list $values + */ + #[DataProvider('provideInvalidAssociateCases')] + public function testInvalidAssociate(string $message, array $values): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage($message); + + $this->collection(static function (): iterable { + yield 1; + + yield 2; + + yield 3; + })->associate($values)->all(); + } + + /** + * @return iterable}> + */ + public static function provideInvalidAssociateCases(): iterable + { + yield 'lesser keys' => [ + 'The number of values is lesser than the keys.', + ['a', 'b'], + ]; + + yield 'greater keys' => [ + 'The number of values is greater than the keys.', + ['a', 'b', 'c', 'd'], + ]; + } + + public function testChunk(): void + { + $collection = $this->collection(['a' => 1, 'b' => 2, 'c' => 3]); + + self::assertSame([['a' => 1, 'b' => 2, 'c' => 3]], $collection->chunk(3)->all(true)); + self::assertSame([['a' => 1, 'b' => 2], ['c' => 3]], $collection->chunk(2)->all(true)); + self::assertSame([['a' => 1], ['b' => 2], ['c' => 3]], $collection->chunk(1)->all(true)); + } + + public function testCount(): void + { + self::assertCount(5, $this->collection()); + self::assertCount(0, $this->collection([])); + self::assertCount(2, $this->collection([1, 2])); + } + + public function testCycle(): void + { + self::assertCount(8, $this->collection([1])->cycle()->limit(8)); + } + + public function testDiff(): void + { + self::assertSame([4, 5], $this->collection()->diff([1, 2, 3])->all()); + self::assertSame(['a' => 1], $this->collection(static function (): iterable { + yield 'a' => 1; + + yield 'b' => 2; + + yield 'c' => 3; + + yield 'd' => new \stdClass(); + })->diff([2], [3], [new \stdClass()])->all(true)); + } + + public function testDiffKey(): void + { + self::assertSame([1, 5], $this->collection()->diffKey([1, 2, 3])->all()); + self::assertSame( + ['a' => 1, 'b' => 2], + $this->collection(static function (): iterable { + yield 'a' => 1; + + yield 'b' => 2; + + yield 'c' => 3; + + yield 'd' => new \stdClass(); + })->diffKey(['d'], ['c'])->all(true), + ); + } + + public function testDrop(): void + { + $collection = $this->collection(['a' => 1, 'b' => 2, 'c' => 3, 'd' => 4, 'e' => 5]); + + self::assertSame(['d' => 4, 'e' => 5], $collection->drop(3)->all(true)); + self::assertSame([], $collection->drop(5)->all(true)); + } + + public function testEvery(): void + { + $collection = $this->collection(); + + self::assertFalse($collection->every(static fn(int $v): bool => $v % 2 === 0)); + self::assertTrue($collection->every(static fn(int $v): bool => $v <= 5)); + } + + public function testFilter(): void + { + $predicate = static fn(int $item): bool => $item > 2; + $collection = $this->collection([0, 1, 2, 3, 4]); + + self::assertSame([3, 4], $collection->filter($predicate)->all()); + self::assertSame([3 => 3, 4 => 4], $collection->filter($predicate)->all(true)); + self::assertSame([1, 2, 3, 4], $collection->filter()->all()); + self::assertSame([], $collection->filter(static fn(int $item): bool => $item < 0)->all()); + } + + public function testFilterKeys(): void + { + $predicate = static fn(string $key): bool => \strlen($key) > 5; + $collection = $this->collection(['apple' => 5, 'banana' => 6]); + + self::assertSame(['banana' => 6], $collection->filterKeys($predicate)->all(true)); + self::assertSame([2, 3, 4, 5], $this->collection()->filterKeys()->all()); + } + + public function testFilterWithKey(): void + { + $predicate = static fn(int $item, string $key): bool => str_starts_with($key, 'd') && $item > 2; + $collection = $this->collection(['banana' => 3, 'apple' => 4, 'dates' => 5, 'dragon fruit' => 0]); + + self::assertSame(['dates' => 5], $collection->filterWithKey($predicate)->all(true)); + self::assertSame(['banana' => 3, 'apple' => 4, 'dates' => 5], $collection->filterWithKey()->all(true)); + } + + public function testFirst(): void + { + $collection = $this->collection([ + 'a' => 'dog', + 'b' => 'cat', + 'c' => 'cow', + 'd' => 'duck', + 'e' => 'goose', + 'f' => 'elephant', + ]); + + self::assertSame('goose', $collection->first(static fn(string $v): bool => \strlen($v) > 4)); + self::assertNull($collection->first(static fn(string $v): bool => str_starts_with($v, 'f'))); + self::assertSame('cow', $collection->first(static fn(string $v, string $k): bool => $v[0] === $k)); + } + + public function testFlip(): void + { + self::assertSame( + [1 => 0, 2 => 1, 3 => 2, 4 => 3, 5 => 4], + $this->collection()->flip()->all(true), + ); + self::assertSame( + ['apple' => 0, 'banana' => 1, 'cat' => 2], + $this->collection(['apple', 'banana', 'cat'])->flip()->all(true), + ); + } + + public function testForget(): void + { + $collection = $this->collection([ + 'a' => 'dog', + 'b' => 'cat', + 'c' => 'cow', + 'd' => 'duck', + 'e' => 'goose', + 'f' => 'elephant', + ]); + + self::assertSame( + ['a' => 'dog', 'e' => 'goose', 'f' => 'elephant'], + $collection->forget('b', 'c', 'd')->all(true), + ); + } + + public function testGet(): void + { + $collection = $this->collection(); + + self::assertSame(3, $collection->get(2, 10)); + self::assertSame(10, $collection->get(5, 10)); + } + + public function testHas(): void + { + $collection = $this->collection(); + + foreach ([0, 1, 2, 3, 4] as $key) { + self::assertTrue($collection->has($key)); + } + + self::assertFalse($collection->has(5)); + } + + public function testIntersect(): void + { + self::assertSame( + [2 => 'c', 3 => [1, 2], 4 => true], + $this->collection(['a', 'b', 'c', [1, 2], true, false]) + ->intersect( + ['b', 'c', [1, 2], true], + ['c', [1, 2], false, true], + ) + ->all(true), + ); + } + + public function testIntersectKey(): void + { + self::assertSame( + ['c' => 3], + $this->collection(['a' => 1, 'b' => 2, 'c' => 3]) + ->intersectKey( + ['b', 'c'], + ['c'], + ) + ->all(true), + ); + } + + public function testKeys(): void + { + $collection = $this->collection(static function (): \Generator { + yield 'bananas' => 5; + + yield 'apples' => 4; + + yield 'oranges' => 7; + }); + + self::assertSame(['bananas', 'apples', 'oranges'], $collection->keys()->all()); + } + + public function testLimit(): void + { + self::assertSame([1, 2, 3, 4, 5], $this->collection()->limit(5)->all()); + self::assertSame([1, 2, 3, 4], $this->collection()->limit(4)->all()); + self::assertSame([1, 2, 3], $this->collection()->limit(3)->all()); + self::assertSame([1, 2], $this->collection()->limit(2)->all()); + self::assertSame([1], $this->collection()->limit(1)->all()); + + self::assertSame( + [1, 2, 1, 2, 1, 2], + $this->collection(new \InfiniteIterator( + new RewindableIterator( + static fn(): \ArrayIterator => new \ArrayIterator([1, 2]), + ), + ))->limit(6)->all(), + ); + } + + public function testMap(): void + { + self::assertSame([1, 4, 9, 16, 25], $this->collection()->map(static fn(int $item): int => $item * $item)->all()); + self::assertSame( + ['a' => '3', 'b' => '3', 'c' => '5', 'd' => '4', 'e' => '4'], + $this->collection(['a' => 'one', 'b' => 'two', 'c' => 'three', 'd' => 'four', 'e' => 'five']) + ->map(static fn(string $item): int => \strlen($item)) + ->map(static fn(int $length): string => (string) $length) + ->all(true), + ); + } + + public function testMapKeys(): void + { + self::assertSame( + [7 => 5, 6 => 4, 5 => 6], + $this->collection(['bananas' => 5, 'apples' => 4, 'limes' => 6]) + ->mapKeys(static fn(string $key): int => \strlen($key)) + ->all(true), + ); + } + + public function testMapWithKey(): void + { + self::assertSame( + ['a' => 1, 'aa' => 4, 'aaa' => 9], + $this->collection(['a' => 1, 'aa' => 2, 'aaa' => 3]) + ->mapWithKey(static fn(int $item, string $key): int => $item * \strlen($key)) + ->all(true), + ); + } + + public function testPartition(): void + { + $collection = $this->collection([ + 1 => 'apple', + 2 => 'banana', + 3 => 'cherry', + 4 => 'date', + ])->partition(static fn(string $v, int $k): bool => str_contains($v, 'a') && $k % 2 === 1); + + self::assertCount(2, $collection); + self::assertSame([1 => 'apple'], $collection->all()[0]->all(true)); + self::assertSame([2 => 'banana', 3 => 'cherry', 4 => 'date'], $collection->all()[1]->all(true)); + } + + public function testReduce(): void + { + self::assertSame( + 25, + $this->collection() + ->reduce(static fn(int $acc, int $v, int $k): int => $acc + $v + $k, 0), + ); + } + + public function testReduction(): void + { + self::assertSame( + [1, 4, 9, 16, 25], + $this->collection() + ->reductions(static fn(int $acc, int $v, int $k): int => $acc + $v + $k, 0) + ->all(), + ); + } + + public function testReject(): void + { + $predicate = static fn(int $item, string $key): bool => str_starts_with($key, 'd') && $item > 2; + $collection = $this->collection(['banana' => 3, 'apple' => 4, 'dates' => 5, 'dragon fruit' => 0]); + + self::assertSame(['banana' => 3, 'apple' => 4, 'dragon fruit' => 0], $collection->reject($predicate)->all(true)); + self::assertSame(['dragon fruit' => 0], $collection->reject()->all(true)); + } + + public function testSlice(): void + { + $collection = $this->collection(['a' => 1, 'b' => 2, 'c' => 3]); + + self::assertSame(['a' => 1, 'b' => 2, 'c' => 3], $collection->slice(1, 0)->all(true)); + self::assertSame(['b' => 2, 'c' => 3], $collection->slice(1)->all(true)); + self::assertSame(['b' => 2], $collection->slice(1, 1)->all(true)); + + self::assertSame( + [-3, -2], + $this->collection([-5, -4, -3, -2, -1, 0]) + ->slice(2, 2) + ->all(), + ); + } + + public function testTake(): void + { + self::assertSame([5, 4, 3], $this->collection([5, 4, 3, 2, 1])->take(3)->all()); + self::assertSame(['a' => 1], $this->collection(['a' => 1, 'b' => 2])->take(1)->all(true)); + } + + public function testTap(): void + { + $stack = []; + $this->collection(['a' => 'apple', 'b' => 'banana'])->tap( + static function (string $item) use (&$stack): void { + $stack[\strlen($item)] = []; + }, + static function (string $item, string $key) use (&$stack): void { + $stack[\strlen($item)][] = [$key, $item]; + }, + )->all(); + + self::assertSame([ + 5 => [['a', 'apple']], + 6 => [['b', 'banana']], + ], $stack); + } + + public function testValues(): void + { + $collection = $this->collection(static function (): \Generator { + yield 'bananas' => 5; + + yield 'apples' => 4; + + yield 'oranges' => 7; + }); + + self::assertSame([5, 4, 7], $collection->values()->all(true)); + } + + public function testCollectionHasMethodsArrangedInParticularOrder(): void + { + $reflection = new \ReflectionClass($this->collection()); + $publicMethods = $reflection->getMethods(\ReflectionMethod::IS_PUBLIC); + $sortedPublicMethods = $publicMethods; + usort( + $sortedPublicMethods, + static function (\ReflectionMethod $a, \ReflectionMethod $b): int { + if ($a->isConstructor()) { + return -1; + } + + if ($b->isConstructor()) { + return 1; + } + + if ($a->isStatic() && ! $b->isStatic()) { + return -1; + } + + if (! $a->isStatic() && $b->isStatic()) { + return 1; + } + + return strcmp($a->getName(), $b->getName()); + }, + ); + $publicMethods = array_map(static fn(\ReflectionMethod $rm): string => $rm->getName(), $publicMethods); + $sortedPublicMethods = array_map(static fn(\ReflectionMethod $rm): string => $rm->getName(), $sortedPublicMethods); + + self::assertSame($sortedPublicMethods, $publicMethods); + } + + /** + * @template TKey + * @template T + * + * @param (\Closure(): iterable)|iterable $items + * + * @return CollectionInterface + */ + abstract protected function collection(\Closure|iterable $items = [1, 2, 3, 4, 5]): CollectionInterface; +} diff --git a/tests/Collection/CollectionTest.php b/tests/Collection/CollectionTest.php new file mode 100644 index 0000000..b710bac --- /dev/null +++ b/tests/Collection/CollectionTest.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Tests\Collection; + +use Nexus\Collection\Collection; +use Nexus\Collection\CollectionInterface; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[CoversClass(Collection::class)] +#[Group('unit-test')] +final class CollectionTest extends AbstractCollectionTestCase +{ + public function testWrap(): void + { + $expected = [1, 2, 3, 4, 5]; + + self::assertSame($expected, iterator_to_array(Collection::wrap($expected))); + self::assertSame($expected, iterator_to_array(Collection::wrap(new \ArrayIterator($expected)))); + self::assertSame($expected, iterator_to_array(Collection::wrap(static fn(): \Generator => yield from $expected))); + self::assertSame($expected, iterator_to_array(Collection::wrap((static fn(): \Generator => yield from $expected)()))); + } + + protected function collection(\Closure|iterable $items = [1, 2, 3, 4, 5]): CollectionInterface + { + return Collection::wrap($items); + } +} diff --git a/tests/Collection/CollectionTypeInferenceTest.php b/tests/Collection/CollectionTypeInferenceTest.php new file mode 100644 index 0000000..29d56a0 --- /dev/null +++ b/tests/Collection/CollectionTypeInferenceTest.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Tests\Collection; + +use PHPStan\Testing\TypeInferenceTestCase; +use PHPUnit\Framework\Attributes\CoversNothing; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[CoversNothing] +#[Group('static-analysis')] +final class CollectionTypeInferenceTest extends TypeInferenceTestCase +{ + #[DataProvider('provideFileAssertsCases')] + public function testFileAsserts(string $assertType, string $file, mixed ...$args): void + { + $this->assertFileAsserts($assertType, $file, ...$args); + } + + /** + * @return iterable> + */ + public static function provideFileAssertsCases(): iterable + { + // @phpstan-ignore generator.valueType + yield from self::gatherAssertTypesFromDirectory(__DIR__.'/data'); + } +} diff --git a/tests/Collection/Iterator/ClosureIteratorAggregateTest.php b/tests/Collection/Iterator/ClosureIteratorAggregateTest.php new file mode 100644 index 0000000..aeb6aa1 --- /dev/null +++ b/tests/Collection/Iterator/ClosureIteratorAggregateTest.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Tests\Collection\Iterator; + +use Nexus\Collection\Iterator\ClosureIteratorAggregate; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\TestCase; + +/** + * @internal + */ +#[CoversClass(ClosureIteratorAggregate::class)] +#[Group('unit-test')] +final class ClosureIteratorAggregateTest extends TestCase +{ + public function testInitialisation(): void + { + $iterator = ClosureIteratorAggregate::from( + static fn(string $item): iterable => yield $item, + 'a', + ); + + self::assertSame(['a'], iterator_to_array($iterator)); + } +} diff --git a/tests/Collection/Iterator/RewindableIteratorTest.php b/tests/Collection/Iterator/RewindableIteratorTest.php new file mode 100644 index 0000000..d14628f --- /dev/null +++ b/tests/Collection/Iterator/RewindableIteratorTest.php @@ -0,0 +1,51 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Tests\Collection\Iterator; + +use Nexus\Collection\Iterator\RewindableIterator; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\TestCase; + +/** + * @internal + */ +#[CoversClass(RewindableIterator::class)] +#[Group('unit-test')] +final class RewindableIteratorTest extends TestCase +{ + public function testRewindableIteratorWorksAsIntended(): void + { + $iterator = new RewindableIterator(static function (): iterable { + yield 0; + + yield 1; + + yield 2; + }); + + self::assertTrue($iterator->valid()); + self::assertSame([0, 0], [$iterator->key(), $iterator->current()]); + $iterator->next(); + self::assertSame([1, 1], [$iterator->key(), $iterator->current()]); + $iterator->next(); + self::assertSame([2, 2], [$iterator->key(), $iterator->current()]); + $iterator->next(); + self::assertFalse($iterator->valid()); + + $iterator->rewind(); + self::assertTrue($iterator->valid()); + self::assertSame([0, 0], [$iterator->key(), $iterator->current()]); + } +} diff --git a/tests/Collection/data/collection.php b/tests/Collection/data/collection.php new file mode 100644 index 0000000..37aedea --- /dev/null +++ b/tests/Collection/data/collection.php @@ -0,0 +1,61 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Tests\Collection; + +use Nexus\Collection\Collection; + +use function PHPStan\Testing\assertType; + +assertType( + 'Nexus\Collection\Collection', + new Collection(static function (): iterable { + yield 'apple' => 1; + }), +); +assertType('Nexus\Collection\Collection', Collection::wrap(['a', 'b', 'c'])); +assertType( + 'Nexus\Collection\Collection', + Collection::wrap(static function (): iterable { + yield 'a' => true; + + yield 'b' => true; + + yield 'c' => false; + }), +); +assertType('Nexus\Collection\Collection', Collection::wrap(new \ArrayIterator([1, 2]))); + +assertType('array', Collection::wrap(['a' => 1, 'b' => 2])->all(true)); +assertType('list', Collection::wrap(['a' => 1, 'b' => 2])->all()); + +$collection = Collection::wrap(['apples' => 10, 'bananas' => 20]); +assertType('Nexus\Collection\Collection', $collection->keys()); +assertType('Nexus\Collection\Collection', $collection->values()); + +assertType('Nexus\Collection\Collection', Collection::wrap([10, 11])->map(static fn(int $item): int => $item ** 2)); +assertType('Nexus\Collection\Collection', Collection::wrap([1])->map(static fn(int $item): string => $item > 1 ? 'Yes' : 'No')); +assertType('Nexus\Collection\Collection, int>', Collection::wrap(['apples' => 2])->mapKeys(static fn(string $key): int => \strlen($key))); +assertType('Nexus\Collection\Collection', Collection::wrap([10])->mapWithKey(static fn(int $v, int $k): int => $v ** $k)); + +assertType('list>', Collection::wrap([5, 4, 3, 2, 1])->chunk(3)->all()); +assertType('Nexus\Collection\Collection>', Collection::wrap([5, 4, 3, 2, 1])->chunk(4)); + +assertType('Nexus\Collection\Collection', Collection::wrap(['a' => 1.5, 'b' => 2.5])->flip()); + +$collection = Collection::wrap([1, 2, 3, 4])->partition(static fn(int $v): bool => $v > 2); +assertType('Nexus\Collection\Collection>', $collection); + +$collection = Collection::wrap([1, 2, 3]); +assertType('Nexus\Collection\Collection', $collection); +assertType('Nexus\Collection\Collection', $collection->associate([1.0, 2.0, 3.0])); diff --git a/tests/Collection/data/iterator.php b/tests/Collection/data/iterator.php new file mode 100644 index 0000000..db56176 --- /dev/null +++ b/tests/Collection/data/iterator.php @@ -0,0 +1,54 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Tests\Collection; + +use Nexus\Collection\Collection; +use Nexus\Collection\Iterator\ClosureIteratorAggregate; +use Nexus\Collection\Iterator\RewindableIterator; + +use function PHPStan\Testing\assertType; + +assertType( + 'Nexus\Collection\Iterator\ClosureIteratorAggregate', + ClosureIteratorAggregate::from( + static fn(string $item): iterable => yield $item, + 'a', + ), +); +assertType( + // @todo Make PHPStan understand the key is 'int' + 'Nexus\Collection\Iterator\ClosureIteratorAggregate', + ClosureIteratorAggregate::from( + static function (iterable $collection): iterable { + foreach ($collection as $key => $item) { + assertType('int', $key); + + yield $key => get_debug_type($item); + } + }, + Collection::wrap([1, new \stdClass()]), + ), +); + +$rewindableIterator = new RewindableIterator( + static function (): iterable { + yield 'a' => 1; + + yield 'b' => 2; + }, +); + +assertType('Nexus\Collection\Iterator\RewindableIterator', $rewindableIterator); +assertType('int', $rewindableIterator->current()); +assertType('string', $rewindableIterator->key()); diff --git a/tools/build-infection.php b/tools/build-infection.php index a6eaaa9..beff259 100644 --- a/tools/build-infection.php +++ b/tools/build-infection.php @@ -19,4 +19,4 @@ __DIR__.'/../infection.json5', json_encode(InfectionConfigBuilder::build(), JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT)."\n", ); -printf("\033[42;30m DONE \033[0m\n"); +printf("\033[42;30m OK \033[0m Done!\n"); diff --git a/tools/src/InfectionConfigBuilder.php b/tools/src/InfectionConfigBuilder.php index 37cacec..711c484 100644 --- a/tools/src/InfectionConfigBuilder.php +++ b/tools/src/InfectionConfigBuilder.php @@ -15,6 +15,7 @@ use Infection\Mutator\ProfileList; use Nexus\Clock\SystemClock; +use Nexus\Collection\Collection; /** * Inspired from https://github.com/kubawerlos/php-cs-fixer-custom-fixers/blob/main/.dev-tools/src/InfectionConfigBuilder.php. @@ -47,12 +48,25 @@ final class InfectionConfigBuilder ]; /** - * @var array> + * A map of mutators with their ignores, which can be + * an FQCN or the method name of a class. + * + * @var array> */ public const PER_MUTATOR_IGNORE = [ + 'CastBool' => [ + Collection::class.'::filterWithKey', + Collection::class.'::reject', + ], 'CastInt' => [SystemClock::class], + 'CastString' => [ + Collection::class.'::toArrayKey', + ], 'Division' => [SystemClock::class], 'ModEqual' => [SystemClock::class], + 'TrueValue' => [ + Collection::class.'::generateDiffHashTable', + ], ]; /**