diff --git a/CHANGELOG.md b/CHANGELOG.md index 3658e7a..77194e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## [1.5.0](https://github.com/geekcell/php-ddd/compare/v1.4.0...v1.5.0) (2024-01-05) + +### Features + +Added a couple of functions to the [Collection](./src/Domain/Collection.php) class to enable a smoother developer experience when trying use it in a more functional style + +The following functions were added: +* `fromIterable` - enables users of the collection to construction the collection from an iterable value like iterators, generators, etc. +* `every` - Returns true if given callback returns truthy values for all items +* `none` - Returns true if given callback returns falsy values for all items +* `some` - Returns true if given callback returns truthy values on some items +* `first` - Get the first element of the collection that matches a callback, if given. Throws exception if collection is empty or predicate is never satisfied +* `firstOr` - Same as first but returns $fallbackValue if collection is empty or predicate is never satisfied +* `last` - Get the last element of the collection that matches a callback, if given. Throws exception if collection is empty or predicate is never satisfied +* `lastOr` - Same as last but returns $fallbackValue if collection is empty or predicate is never satisfied +* `isEmpty` - Returns whether the collection is empty +* `hasItems` - Returns whether the collection has items + ## [1.4.0](https://github.com/geekcell/php-ddd/compare/v1.3.1...v1.4.0) (2023-12-19) diff --git a/src/Domain/Collection.php b/src/Domain/Collection.php index d8e2b34..f1d99e2 100644 --- a/src/Domain/Collection.php +++ b/src/Domain/Collection.php @@ -4,14 +4,28 @@ namespace GeekCell\Ddd\Domain; +use ArrayAccess; +use ArrayIterator; use Assert; +use Countable; +use InvalidArgumentException; +use IteratorAggregate; +use Traversable; +use function array_filter; +use function array_map; +use function array_reduce; +use function array_values; +use function count; +use function get_class; +use function is_int; +use function reset; /** * @template T of object - * @implements \IteratorAggregate - * @implements \ArrayAccess + * @implements IteratorAggregate + * @implements ArrayAccess */ -class Collection implements \ArrayAccess, \Countable, \IteratorAggregate +class Collection implements ArrayAccess, Countable, IteratorAggregate { /** * @param T[] $items @@ -26,11 +40,218 @@ final public function __construct( } } + /** + * Creates a collection from a given iterable of items. + * This function is useful when trying to create a collection from a generator or an iterator. + * + * @param iterable $items + * @param class-string|null $itemType + * @return self + * @throws Assert\AssertionFailedException + */ + public static function fromIterable(iterable $items, ?string $itemType = null): static + { + if (is_array($items)) { + return new static($items, $itemType); + } + + if (!$items instanceof Traversable) { + $items = [...$items]; + } + + return new static(iterator_to_array($items), $itemType); + } + + /** + * Returns true if every value in the collection passes the callback truthy test. Opposite of self::none(). + * Callback arguments will be element, index, collection. + * Function short-circuits on first falsy return value. + * + * @param ?callable(T, int, static): bool $callback + * @return bool + */ + public function every(callable $callback = null): bool + { + if ($callback === null) { + $callback = static fn ($item, $index, $self) => $item; + } + + foreach ($this->items as $index => $item) { + if (!$callback($item, $index, $this)) { + return false; + } + } + + return true; + } + + /** + * Returns true if every value in the collection passes the callback falsy test. Opposite of self::every(). + * Callback arguments will be element, index, collection. + * Function short-circuits on first truthy return value. + * + * @param ?callable(T, int, static): bool $callback + * @return bool + */ + public function none(callable $callback = null): bool + { + if ($callback === null) { + $callback = static fn ($item, $index, $self) => $item; + } + + foreach ($this->items as $index => $item) { + if ($callback($item, $index, $this)) { + return false; + } + } + + return true; + } + + /** + * Returns true if at least one value in the collection passes the callback truthy test. + * Callback arguments will be element, index, collection. + * Function short-circuits on first truthy return value. + * + * @param ?callable(T, int, static): bool $callback + * @return bool + */ + public function some(callable $callback = null): bool + { + if ($callback === null) { + $callback = static fn ($item, $index, $self) => $item; + } + + foreach ($this->items as $index => $item) { + if ($callback($item, $index, $this)) { + return true; + } + } + + return false; + } + + /** + * Returns the first element of the collection that matches the given callback. + * If no callback is given the first element in the collection is returned. + * Throws exception if collection is empty or the given callback was never satisfied. + * + * @param ?callable(T, int, static): bool $callback + * @return T + * @throws InvalidArgumentException + */ + public function first(callable $callback = null) + { + if ($this->items === []) { + throw new InvalidArgumentException('No items in collection'); + } + + foreach ($this->items as $index => $item) { + if ($callback === null || $callback($item, $index, $this)) { + return $item; + } + } + + throw new InvalidArgumentException('No item found in collection that satisfies first callback'); + } + + /** + * Returns the first element of the collection that matches the given callback. + * If no callback is given the first element in the collection is returned. + * If the collection is empty the given fallback value is returned instead. + * + * @template U of T|mixed + * @param ?callable(T, int, static): bool $callback + * @param U $fallbackValue + * @return U + * @throws InvalidArgumentException + */ + public function firstOr(callable $callback = null, mixed $fallbackValue = null) + { + if ($this->items === []) { + return $fallbackValue; + } + + foreach ($this->items as $index => $item) { + if ($callback === null || $callback($item, $index, $this)) { + return $item; + } + } + + return $fallbackValue; + } + + /** + * Returns the last element of the collection that matches the given callback. + * If no callback is given the last element in the collection is returned. + * Throws exception if collection is empty or the given callback was never satisfied. + * + * @param ?callable(T, int, static): bool $callback + * @return T + * @throws InvalidArgumentException + */ + public function last(callable $callback = null) + { + if ($this->items === []) { + throw new InvalidArgumentException('No items in collection'); + } + + foreach (array_reverse($this->items) as $index => $item) { + if ($callback === null || $callback($item, $index, $this)) { + return $item; + } + } + + throw new InvalidArgumentException('No item found in collection that satisfies last callback'); + } + + /** + * Returns the last element of the collection that matches the given callback. + * If no callback is given the last element in the collection is returned. + * If the collection is empty the given fallback value is returned instead. + * + * @template U of T|mixed + * @param ?callable(T, int, static): bool $callback + * @param U $fallbackValue + * @return U + * @throws InvalidArgumentException + */ + public function lastOr(callable $callback = null, mixed $fallbackValue = null) + { + if ($this->items === []) { + return $fallbackValue; + } + + foreach (array_reverse($this->items) as $index => $item) { + if ($callback === null || $callback($item, $index, $this)) { + return $item; + } + } + + return $fallbackValue; + } + + /** + * Returns whether the collection is empty (has no items) + */ + public function isEmpty(): bool + { + return $this->items === []; + } + + /** + * Returns whether the collection has items + */ + public function hasItems(): bool + { + return $this->items !== []; + } + /** * Add one or more items to the collection. It **does not** modify the * current collection, but returns a new one. * - * @param mixed $item One or more items to add to the collection. + * @param T|iterable $item One or more items to add to the collection. * @return static */ public function add(mixed $item): static @@ -56,7 +277,7 @@ public function add(mixed $item): static public function filter(callable $callback): static { return new static( - \array_values(\array_filter($this->items, $callback)), + array_values(array_filter($this->items, $callback)), $this->itemType, ); } @@ -74,15 +295,15 @@ public function filter(callable $callback): static */ public function map(callable $callback, bool $inferTypes = true): static { - $mapResult = \array_map($callback, $this->items); - $firstItem = \reset($mapResult); + $mapResult = array_map($callback, $this->items); + $firstItem = reset($mapResult); if ($firstItem === false || !is_object($firstItem)) { return new static($mapResult); } if ($inferTypes && $this->itemType !== null) { - return new static($mapResult, \get_class($firstItem)); + return new static($mapResult, get_class($firstItem)); } return new static($mapResult); @@ -98,7 +319,7 @@ public function map(callable $callback, bool $inferTypes = true): static */ public function reduce(callable $callback, mixed $initial = null): mixed { - return \array_reduce($this->items, $callback, $initial); + return array_reduce($this->items, $callback, $initial); } /** @@ -106,7 +327,7 @@ public function reduce(callable $callback, mixed $initial = null): mixed */ public function offsetExists(mixed $offset): bool { - if (!\is_int($offset)) { + if (!is_int($offset)) { return false; } @@ -152,14 +373,14 @@ public function offsetUnset(mixed $offset): void */ public function count(): int { - return \count($this->items); + return count($this->items); } /** * @inheritDoc */ - public function getIterator(): \Traversable + public function getIterator(): Traversable { - return new \ArrayIterator($this->items); + return new ArrayIterator($this->items); } } diff --git a/tests/Domain/CollectionTest.php b/tests/Domain/CollectionTest.php index 86044d5..ded3457 100644 --- a/tests/Domain/CollectionTest.php +++ b/tests/Domain/CollectionTest.php @@ -4,8 +4,10 @@ namespace GeekCell\Ddd\Tests\Domain; +use ArrayIterator; use Assert; use GeekCell\Ddd\Domain\Collection; +use InvalidArgumentException; use PHPUnit\Framework\TestCase; /** @@ -300,4 +302,240 @@ public function testChaining(): void // Then $this->assertEquals(60, $result); } + + public function testFromIterable(): void + { + $items = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + $collectionFromArray = Collection::fromIterable($items); + $this->assertSame($items, iterator_to_array($collectionFromArray)); + + $collectionFromIterator = Collection::fromIterable(new ArrayIterator($items)); + $this->assertSame($items, iterator_to_array($collectionFromIterator)); + + $generatorFn = static function () use ($items) { + foreach ($items as $item) { + yield $item; + } + }; + + $collectionFromGenerator = Collection::fromIterable($generatorFn()); + $this->assertSame($items, iterator_to_array($collectionFromGenerator)); + } + + public function testEvery(): void + { + $items = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + $collection = new Collection($items); + + $this->assertFalse($collection->every(static fn ($item) => $item > 10)); + $this->assertFalse($collection->every(static fn ($item) => $item > 5)); + $this->assertTrue($collection->every(static fn ($item) => $item > 0)); + } + + public function testEveryWithoutArgumentDefaultsToTruthyCheck(): void + { + $this->assertTrue((new Collection([1, true]))->every()); + $this->assertTrue((new Collection([1, true]))->every()); + $this->assertFalse((new Collection([null, false]))->every()); + $this->assertFalse((new Collection([false, null]))->every()); + $this->assertFalse((new Collection([0, false]))->every()); + } + + public function testEveryReturnsTrueOnEmptyCollection(): void + { + $this->assertTrue((new Collection())->every(static fn ($item) => false)); + } + + public function testEveryShortCircuitsOnFirstFalsyValue(): void + { + $items = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + $collection = new Collection($items); + + $collection->every(function ($item, $index, $c) use ($collection): bool { + // First item already returns false therefore the index should never be something other than 0 + $this->assertSame(0, $index); + $this->assertSame($c, $collection); + return false; + }); + } + + public function testNone(): void + { + $items = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + $collection = new Collection($items); + + $this->assertTrue($collection->none(static fn ($item) => $item > 10)); + $this->assertFalse($collection->none(static fn ($item) => $item > 5)); + $this->assertFalse($collection->none(static fn ($item) => $item > 0)); + } + + public function testNoneWithoutArgumentDefaultsToTruthyCheck(): void + { + $this->assertFalse((new Collection([1, true]))->none()); + $this->assertFalse((new Collection([1, true]))->none()); + $this->assertTrue((new Collection([null, false]))->none()); + $this->assertTrue((new Collection([false, null]))->none()); + $this->assertTrue((new Collection([0, false]))->none()); + } + + public function testNoneReturnsFalseOnEmptyCollection(): void + { + $this->assertTrue((new Collection())->none(static fn ($item) => true)); + } + + public function testNoneShortCircuitsOnFirstFalsyValue(): void + { + $items = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + $collection = new Collection($items); + + $collection->none(function ($item, $index, $c) use ($collection): bool { + // First item already returns true therefore the index should never be something other than 0 + $this->assertSame(0, $index); + $this->assertSame($c, $collection); + return true; + }); + } + + public function testSome(): void + { + $items = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + $collection = new Collection($items); + + $this->assertFalse($collection->some(static fn ($item) => $item > 10)); + $this->assertTrue($collection->some(static fn ($item) => $item > 5)); + $this->assertTrue($collection->some(static fn ($item) => $item > 0)); + } + + public function testSomeWithoutArgumentDefaultsToTruthyCheck(): void + { + $this->assertTrue((new Collection([1, true]))->some()); + $this->assertTrue((new Collection([1, true]))->some()); + $this->assertFalse((new Collection([null, false]))->some()); + $this->assertFalse((new Collection([false, null]))->some()); + $this->assertFalse((new Collection([0, false]))->some()); + } + + public function testSomeReturnsFalseOnEmptyCollection(): void + { + $this->assertFalse((new Collection())->some(static fn ($item) => true)); + } + + public function testSomeShortCircuitsOnFirstFalsyValue(): void + { + $items = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + $collection = new Collection($items); + + $collection->some(function ($item, $index, $c) use ($collection): bool { + // First item already returns true therefore the index should never be something other than 0 + $this->assertSame(0, $index); + $this->assertSame($c, $collection); + return true; + }); + } + + public function testFirst(): void + { + $items = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + $collection = new Collection($items); + + $this->assertSame(1, $collection->first()); + } + + public function testFirstThrowsExceptionOnEmptyCollection(): void + { + $collection = new Collection([]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('No items in collection'); + $collection->first(); + } + + public function testFirstThrowsExceptionIfCallbackIsNeverSatisfied(): void + { + $items = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + $collection = new Collection($items); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('No item found in collection that satisfies first callback'); + $collection->first(static fn () => false); + } + + public function testFirstOrReturnsFirstValueInCollectionIfNoCallbackIsGiven(): void + { + $items = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + $collection = new Collection($items); + $this->assertSame(1, $collection->firstOr()); + } + + public function testFirstOrReturnsFirstValueThatSatisfiesCallback(): void + { + $items = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + $collection = new Collection($items); + $this->assertSame(6, $collection->firstOr(static fn ($item) => $item > 5)); + } + + public function testFirstOrReturnsFallbackValueIfCallbackIsNeverSatisfied(): void + { + $items = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + $collection = new Collection($items); + $this->assertSame(-1, $collection->firstOr(static fn ($item) => $item > 10, -1)); + } + + public function testLast(): void + { + $items = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + $collection = new Collection($items); + + $this->assertSame(10, $collection->last()); + } + + public function testLastThrowsExceptionOnEmptyCollection(): void + { + $collection = new Collection([]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('No items in collection'); + $collection->last(); + } + + public function testLastThrowsExceptionIfCallbackIsNeverSatisfied(): void + { + $items = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + $collection = new Collection($items); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('No item found in collection that satisfies last callback'); + $collection->last(static fn () => false); + } + + public function testLastOrReturnsLastValueInCollectionIfNoCallbackIsGiven(): void + { + $items = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + $collection = new Collection($items); + $this->assertSame(10, $collection->lastOr()); + } + + public function testLastOrReturnsLastValueThatSatisfiesCallback(): void + { + $items = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + $collection = new Collection($items); + $this->assertSame(10, $collection->lastOr(static fn ($item) => $item > 5)); + } + + public function testLastOrReturnsFallbackValueIfCallbackIsNeverSatisfied(): void + { + $items = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + $collection = new Collection($items); + $this->assertSame(-1, $collection->lastOr(static fn ($item) => $item > 10, -1)); + } + + public function testIsEmpty(): void + { + $this->assertFalse((new Collection([1]))->isEmpty()); + $this->assertTrue((new Collection([]))->isEmpty()); + } + + public function testHasItems(): void + { + $this->assertFalse((new Collection([]))->hasItems()); + $this->assertTrue((new Collection([1]))->hasItems()); + } }