From 2795eb9113c22dc9169eb08233aa70f019456fb5 Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Tue, 8 Oct 2024 11:54:00 -0300 Subject: [PATCH] feat: Adds groupBy method to Collection for grouping elements by a specified criterion. (#12) --- .gitattributes | 3 +- README.md | 24 +++-- composer.json | 1 + phpstan.neon.dist | 6 +- src/Collectible.php | 13 ++- src/Collection.php | 9 +- src/Internal/Iterators/InternalIterator.php | 3 + src/Internal/Operations/Filter/Filter.php | 2 +- .../Operations/ImmediateOperation.php | 2 - src/Internal/Operations/LazyOperation.php | 1 - src/Internal/Operations/Transform/Each.php | 2 +- src/Internal/Operations/Transform/GroupBy.php | 35 +++++++ src/Internal/Operations/Transform/Map.php | 2 +- src/Internal/Operations/Write/Create.php | 2 +- .../CollectionGroupByOperationTest.php | 98 +++++++++++++++++++ 15 files changed, 177 insertions(+), 26 deletions(-) create mode 100644 src/Internal/Operations/Transform/GroupBy.php create mode 100644 tests/Operations/Transform/CollectionGroupByOperationTest.php diff --git a/.gitattributes b/.gitattributes index 8efe2ca..22aac70 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,9 +1,9 @@ /tests export-ignore /vendor export-ignore -/README.md export-ignore /LICENSE export-ignore /Makefile export-ignore +/README.md export-ignore /phpmd.xml export-ignore /phpunit.xml export-ignore /phpstan.neon.dist export-ignore @@ -11,3 +11,4 @@ /.github export-ignore /.gitignore export-ignore +/.gitattributes export-ignore diff --git a/README.md b/README.md index 80b9f5b..06a10cd 100644 --- a/README.md +++ b/README.md @@ -259,22 +259,30 @@ combining elements. These methods allow the Collection's elements to be transformed or converted into different formats. -#### Mapping elements +#### Applying actions without modifying elements -- `map`: Applies transformations to each element in the Collection and returns a new collection with the transformed - elements. +- `each`: Executes actions on each element in the Collection without modification. + The method is helpful for performing side effects, such as logging or adding elements to another collection. ```php - $collection->map(transformations: fn(int $value): int => $value * 2); + $collection->each(actions: fn(Invoice $invoice): void => $collectionB->add(elements: new InvoiceSummary(amount: $invoice->amount, customer: $invoice->customer))); ``` + +#### Grouping elements -#### Applying actions without modifying elements +- `groupBy`: Groups the elements in the Collection based on the provided grouping criterion. -- `each`: Executes actions on each element in the Collection without modification. - The method is helpful for performing side effects, such as logging or adding elements to another collection. + ```php + $collection->groupBy(grouping: fn(Amount $amount): string => $amount->currency->name); + ``` + +#### Mapping elements + +- `map`: Applies transformations to each element in the Collection and returns a new collection with the transformed + elements. ```php - $collection->each(actions: fn(Invoice $invoice): void => $collectionB->add(elements: new InvoiceSummary(amount: $invoice->amount, customer: $invoice->customer))); + $collection->map(transformations: fn(int $value): int => $value * 2); ``` #### Convert to array diff --git a/composer.json b/composer.json index 66a8277..f557f31 100644 --- a/composer.json +++ b/composer.json @@ -12,6 +12,7 @@ "array", "yield", "iterator", + "iterators", "generator", "collection", "tiny-blocks" diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 960f73d..718ae62 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -6,9 +6,5 @@ parameters: ignoreErrors: - '#Unsafe usage of new static#' - '#does not specify its types#' - - '#contains incompatible type#' - - '#specified in iterable type iterable#' - - '#is not subtype of native type static#' - - '#PHPDoc tag @extends has invalid value#' - - '#type has no value type specified in iterable type array#' + - '#type specified in iterable type#' reportUnmatchedIgnoredErrors: false diff --git a/src/Collectible.php b/src/Collectible.php index 3abf200..0b56811 100644 --- a/src/Collectible.php +++ b/src/Collectible.php @@ -27,14 +27,14 @@ interface Collectible extends Countable, IteratorAggregate * @param iterable $elements The elements to initialize the collection with. * @return Collectible A new Collectible instance. */ - public static function createFrom(iterable $elements): static; + public static function createFrom(iterable $elements): Collectible; /** * Creates an empty Collectible instance. * * @return Collectible An empty Collectible instance. */ - public static function createFromEmpty(): static; + public static function createFromEmpty(): Collectible; /** * Adds one or more elements to the collection. @@ -116,6 +116,15 @@ public function getBy(int $index, mixed $defaultValueIfNotFound = null): mixed; */ public function getIterator(): Traversable; + /** + * Groups the elements in the collection based on the provided criteria. + * + * @param Closure(Element): Key $grouping The function to define the group key for each element. + * @return Collectible, Element> A collection of collections, + * grouped by the key returned by the closure. + */ + public function groupBy(Closure $grouping): Collectible; + /** * Determines if the collection is empty. * diff --git a/src/Collection.php b/src/Collection.php index 8ab870e..31f4de5 100644 --- a/src/Collection.php +++ b/src/Collection.php @@ -17,6 +17,7 @@ use TinyBlocks\Collection\Internal\Operations\Retrieve\Get; use TinyBlocks\Collection\Internal\Operations\Retrieve\Last; use TinyBlocks\Collection\Internal\Operations\Transform\Each; +use TinyBlocks\Collection\Internal\Operations\Transform\GroupBy; use TinyBlocks\Collection\Internal\Operations\Transform\Map; use TinyBlocks\Collection\Internal\Operations\Transform\MapToArray; use TinyBlocks\Collection\Internal\Operations\Transform\MapToJson; @@ -31,9 +32,6 @@ * Represents a collection that provides a set of utility methods for operations like adding, * filtering, mapping, and transforming elements. Internally uses iterators to apply operations * lazily and efficiently. - * - * @template Element of mixed - * @implements Collectible */ class Collection implements Collectible { @@ -108,6 +106,11 @@ public function getIterator(): Traversable yield from $this->iterator->getIterator(); } + public function groupBy(Closure $grouping): Collectible + { + return new static(iterator: $this->iterator->add(operation: GroupBy::from(grouping: $grouping))); + } + public function isEmpty(): bool { return !$this->iterator->getIterator()->valid(); diff --git a/src/Internal/Iterators/InternalIterator.php b/src/Internal/Iterators/InternalIterator.php index 647051d..4ddbda4 100644 --- a/src/Internal/Iterators/InternalIterator.php +++ b/src/Internal/Iterators/InternalIterator.php @@ -18,6 +18,9 @@ */ final class InternalIterator implements IteratorAggregate { + /** + * @var LazyOperation[] + */ private array $operations; /** diff --git a/src/Internal/Operations/Filter/Filter.php b/src/Internal/Operations/Filter/Filter.php index 0d2da64..560ad4a 100644 --- a/src/Internal/Operations/Filter/Filter.php +++ b/src/Internal/Operations/Filter/Filter.php @@ -8,7 +8,7 @@ use Generator; use TinyBlocks\Collection\Internal\Operations\LazyOperation; -final class Filter implements LazyOperation +final readonly class Filter implements LazyOperation { private array $predicates; diff --git a/src/Internal/Operations/ImmediateOperation.php b/src/Internal/Operations/ImmediateOperation.php index f9c18cb..0b9891e 100644 --- a/src/Internal/Operations/ImmediateOperation.php +++ b/src/Internal/Operations/ImmediateOperation.php @@ -6,8 +6,6 @@ /** * Interface for operations that are applied immediately and not lazily. - * - * @extends Operation */ interface ImmediateOperation extends Operation { diff --git a/src/Internal/Operations/LazyOperation.php b/src/Internal/Operations/LazyOperation.php index a1a3956..de81749 100644 --- a/src/Internal/Operations/LazyOperation.php +++ b/src/Internal/Operations/LazyOperation.php @@ -11,7 +11,6 @@ * * @template Key of array-key * @template Value - * @extends Operation */ interface LazyOperation extends Operation { diff --git a/src/Internal/Operations/Transform/Each.php b/src/Internal/Operations/Transform/Each.php index 7705cc3..1c402ca 100644 --- a/src/Internal/Operations/Transform/Each.php +++ b/src/Internal/Operations/Transform/Each.php @@ -7,7 +7,7 @@ use Closure; use TinyBlocks\Collection\Internal\Operations\ImmediateOperation; -final class Each implements ImmediateOperation +final readonly class Each implements ImmediateOperation { private array $actions; diff --git a/src/Internal/Operations/Transform/GroupBy.php b/src/Internal/Operations/Transform/GroupBy.php new file mode 100644 index 0000000..62ad062 --- /dev/null +++ b/src/Internal/Operations/Transform/GroupBy.php @@ -0,0 +1,35 @@ +grouping)($element); + $groupedElements[$key][] = $element; + } + + foreach ($groupedElements as $key => $group) { + yield $key => $group; + } + } +} diff --git a/src/Internal/Operations/Transform/Map.php b/src/Internal/Operations/Transform/Map.php index e2955a1..16afe45 100644 --- a/src/Internal/Operations/Transform/Map.php +++ b/src/Internal/Operations/Transform/Map.php @@ -8,7 +8,7 @@ use Generator; use TinyBlocks\Collection\Internal\Operations\LazyOperation; -final class Map implements LazyOperation +final readonly class Map implements LazyOperation { private array $transformations; diff --git a/src/Internal/Operations/Write/Create.php b/src/Internal/Operations/Write/Create.php index 9846556..ef8c04d 100644 --- a/src/Internal/Operations/Write/Create.php +++ b/src/Internal/Operations/Write/Create.php @@ -7,7 +7,7 @@ use Generator; use TinyBlocks\Collection\Internal\Operations\LazyOperation; -final class Create implements LazyOperation +final readonly class Create implements LazyOperation { public static function fromEmpty(): Create { diff --git a/tests/Operations/Transform/CollectionGroupByOperationTest.php b/tests/Operations/Transform/CollectionGroupByOperationTest.php new file mode 100644 index 0000000..45a3ca4 --- /dev/null +++ b/tests/Operations/Transform/CollectionGroupByOperationTest.php @@ -0,0 +1,98 @@ +groupBy(grouping: static fn(Amount $amount): string => $amount->currency->name); + + /** @Then the collection should be grouped by the currency */ + $expected = Collection::createFrom(elements: [ + 'BRL' => [ + new Amount(value: 55.1, currency: Currency::BRL), + new Amount(value: 23.3, currency: Currency::BRL) + ], + 'USD' => [ + new Amount(value: 100.5, currency: Currency::USD), + new Amount(value: 200.0, currency: Currency::USD) + ] + ]); + + self::assertEquals($expected->toArray(), $actual->toArray()); + } + + public function testGroupBySimpleKey(): void + { + /** @Given a collection of elements with a type property */ + $collection = Collection::createFrom(elements: [ + ['type' => 'fruit', 'name' => 'apple'], + ['type' => 'fruit', 'name' => 'banana'], + ['type' => 'vegetable', 'name' => 'carrot'], + ['type' => 'vegetable', 'name' => 'broccoli'] + ]); + + /** @When grouping by the 'type' key */ + $actual = $collection->groupBy(grouping: static fn(array $item): string => $item['type']); + + /** @Then the collection should be grouped by the type property */ + $expected = Collection::createFrom(elements: [ + 'fruit' => Collection::createFrom(elements: [ + ['type' => 'fruit', 'name' => 'apple'], + ['type' => 'fruit', 'name' => 'banana'] + ]), + 'vegetable' => Collection::createFrom(elements: [ + ['type' => 'vegetable', 'name' => 'carrot'], + ['type' => 'vegetable', 'name' => 'broccoli'] + ]) + ]); + + self::assertSame($expected->toArray(), $actual->toArray()); + } + + public function testGroupByNumericKey(): void + { + /** @Given a collection of numbers */ + $collection = Collection::createFrom(elements: [1, 2, 3, 4, 5, 6]); + + /** @When grouping by even and odd numbers */ + $actual = $collection->groupBy(grouping: static fn(int $item): string => $item % 2 === 0 ? 'even' : 'odd'); + + /** @Then the collection should be grouped into even and odd */ + $expected = Collection::createFrom(elements: [ + 'odd' => Collection::createFrom(elements: [1, 3, 5]), + 'even' => Collection::createFrom(elements: [2, 4, 6]) + ]); + + self::assertSame($expected->toArray(), $actual->toArray()); + } + + public function testGroupByEmptyCollection(): void + { + /** @Given an empty collection */ + $collection = Collection::createFromEmpty(); + + /** @When applying groupBy on the empty collection */ + $actual = $collection->groupBy(grouping: static fn(array $item): array => $item); + + /** @Then the collection should remain empty */ + self::assertEmpty($actual->toArray()); + } +}