diff --git a/README.md b/README.md index 750a10a..0818d62 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ * [Ordering](#ordering) * [Retrieving](#retrieving) * [Comparing](#comparing) + * [Aggregation](#aggregation) * [Transforming](#transforming) * [License](#license) * [Contributing](#contributing) @@ -208,6 +209,19 @@ These methods enable comparing collections to check for equality or to apply oth
+### Aggregation + +These methods perform operations that return a single value based on the collection's content, such as summing or +combining elements. + +- `reduce`: Combines all elements in the collection into a single value using the provided aggregator function and an + initial value. + This method is useful for accumulating results, like summing or concatenating values. + + ```php + $collection->reduce(aggregator: fn(float $carry, float $amount): float => $carry + $amount, initial: 0.0) + ``` + ### Transforming These methods allow the collection's elements to be transformed or converted into different formats. diff --git a/composer.json b/composer.json index e556434..bd3ee02 100644 --- a/composer.json +++ b/composer.json @@ -13,6 +13,7 @@ "yield", "psr-4", "psr-12", + "iterator", "generator", "collection", "tiny-blocks" @@ -45,9 +46,9 @@ "tiny-blocks/value-object": "^2" }, "require-dev": { - "infection/infection": "^0.29", "phpmd/phpmd": "^2.15", "phpunit/phpunit": "^11", + "infection/infection": "^0.29", "squizlabs/php_codesniffer": "^3.10" }, "scripts": { diff --git a/src/Collectible.php b/src/Collectible.php index 8a5a295..4b5242a 100644 --- a/src/Collectible.php +++ b/src/Collectible.php @@ -14,48 +14,46 @@ /** * Represents a collection that can be manipulated, iterated, and counted. * - * @template Key of array-key - * @template Value - * @extends Countable - * @extends IteratorAggregate + * @template Element + * @extends IteratorAggregate */ interface Collectible extends Countable, IteratorAggregate { /** * Creates a new Collectible instance from the given elements. * - * @param iterable $elements The elements to initialize the collection with. - * @return Collectible A new Collectible instance. + * @param iterable $elements The elements to initialize the collection with. + * @return Collectible A new Collectible instance. */ - public static function createFrom(iterable $elements): Collectible; + public static function createFrom(iterable $elements): static; /** * Creates an empty Collectible instance. * - * @return Collectible An empty Collectible instance. + * @return Collectible An empty Collectible instance. */ - public static function createFromEmpty(): Collectible; + public static function createFromEmpty(): static; /** * Adds one or more elements to the collection. * - * @param mixed ...$elements The elements to add to the collection. - * @return Collectible The updated collection. + * @param Element ...$elements The elements to add to the collection. + * @return Collectible The updated collection. */ public function add(mixed ...$elements): Collectible; /** * Executes actions on each element in the collection without modifying it. * - * @param Closure ...$actions The actions to perform on each element. - * @return Collectible The original collection for chaining. + * @param Closure(Element): void ...$actions The actions to perform on each element. + * @return Collectible The original collection for chaining. */ public function each(Closure ...$actions): Collectible; /** * Compares the collection with another collection for equality. * - * @param Collectible $other The collection to compare with. + * @param Collectible $other The collection to compare with. * @return bool True if the collections are equal, false otherwise. */ public function equals(Collectible $other): bool; @@ -64,24 +62,24 @@ public function equals(Collectible $other): bool; * Filters elements in the collection based on the provided predicates. * If no predicates are provided, all empty or falsy values (e.g., null, false, empty arrays) will be removed. * - * @param Closure|null ...$predicates - * @return Collectible The updated collection. + * @param Closure(Element): bool|null ...$predicates + * @return Collectible The updated collection. */ public function filter(?Closure ...$predicates): Collectible; /** * Finds the first element matching the provided predicates. * - * @param Closure ...$predicates The predicates to match. - * @return mixed The first matching element, or null if none found. + * @param Closure(Element): bool ...$predicates The predicates to match. + * @return Element|null The first matching element, or null if none found. */ public function findBy(Closure ...$predicates): mixed; /** * Retrieves the first element in the collection, or a default value if not found. * - * @param mixed $defaultValueIfNotFound The default value to return if no element is found. - * @return mixed The first element or the default value. + * @param Element|null $defaultValueIfNotFound The default value to return if no element is found. + * @return Element|null The first element or the default value. */ public function first(mixed $defaultValueIfNotFound = null): mixed; @@ -96,15 +94,15 @@ public function count(): int; * Retrieves an element by its index, or a default value if not found. * * @param int $index The index of the element to retrieve. - * @param mixed $defaultValueIfNotFound The default value to return if no element is found. - * @return mixed The element at the specified index or the default value. + * @param Element|null $defaultValueIfNotFound The default value to return if no element is found. + * @return Element|null The element at the specified index or the default value. */ public function getBy(int $index, mixed $defaultValueIfNotFound = null): mixed; /** * Returns an iterator for traversing the collection. * - * @return Traversable An iterator for the collection. + * @return Traversable An iterator for the collection. */ public function getIterator(): Traversable; @@ -118,8 +116,8 @@ public function isEmpty(): bool; /** * Retrieves the last element in the collection, or a default value if not found. * - * @param mixed $defaultValueIfNotFound The default value to return if no element is found. - * @return mixed The last element or the default value. + * @param Element|null $defaultValueIfNotFound The default value to return if no element is found. + * @return Element|null The last element or the default value. */ public function last(mixed $defaultValueIfNotFound = null): mixed; @@ -127,16 +125,16 @@ public function last(mixed $defaultValueIfNotFound = null): mixed; * Applies transformations to each element in the collection and returns a new collection with the transformed * elements. * - * @param Closure(Value): Value ...$transformations The transformations to apply. - * @return Collectible A new collection with the applied transformations. + * @param Closure(Element): Element ...$transformations The transformations to apply. + * @return Collectible A new collection with the applied transformations. */ public function map(Closure ...$transformations): Collectible; /** * Removes a specific element from the collection. * - * @param mixed $element The element to remove. - * @return Collectible The updated collection. + * @param Element $element The element to remove. + * @return Collectible The updated collection. */ public function remove(mixed $element): Collectible; @@ -144,17 +142,27 @@ public function remove(mixed $element): Collectible; * Removes elements from the collection based on the provided filter. * If no filter is passed, all elements in the collection will be removed. * - * @param Closure|null $filter The filter to determine which elements to remove. - * @return Collectible The updated collection. + * @param Closure(Element): bool|null $filter The filter to determine which elements to remove. + * @return Collectible The updated collection. */ public function removeAll(?Closure $filter = null): Collectible; + /** + * Reduces the elements in the collection to a single value by applying an aggregator function. + * + * @param Closure(mixed, Element): mixed $aggregator The function that aggregates the elements. + * It receives the current accumulated value and the current element. + * @param mixed $initial The initial value to start the aggregation. + * @return mixed The final aggregated result. + */ + public function reduce(Closure $aggregator, mixed $initial): mixed; + /** * Sorts the collection based on the provided order and predicate. * * @param Order $order The order in which to sort the collection. - * @param Closure|null $predicate The predicate to use for sorting. - * @return Collectible The updated collection. + * @param Closure(Element, Element): int|null $predicate The predicate to use for sorting. + * @return Collectible The updated collection. */ public function sort(Order $order = Order::ASCENDING_KEY, ?Closure $predicate = null): Collectible; @@ -162,7 +170,7 @@ public function sort(Order $order = Order::ASCENDING_KEY, ?Closure $predicate = * Converts the collection to an array. * * @param PreserveKeys $preserveKeys The option to preserve array keys. - * @return array The resulting array. + * @return array The resulting array. */ public function toArray(PreserveKeys $preserveKeys = PreserveKeys::PRESERVE): array; diff --git a/src/Collection.php b/src/Collection.php index 1e55847..d060c36 100644 --- a/src/Collection.php +++ b/src/Collection.php @@ -6,6 +6,7 @@ use Closure; use TinyBlocks\Collection\Internal\Iterators\InternalIterator; +use TinyBlocks\Collection\Internal\Operations\Aggregate\Reduce; use TinyBlocks\Collection\Internal\Operations\ApplicableOperation; use TinyBlocks\Collection\Internal\Operations\Compare\Equals; use TinyBlocks\Collection\Internal\Operations\Filter\Filter; @@ -31,9 +32,8 @@ * filtering, mapping, and transforming elements. Internally uses iterators to apply operations * lazily and efficiently. * - * @template Key of array-key - * @template Value - * @implements Collectible + * @template Element + * @implements Collectible */ class Collection implements Collectible { @@ -44,14 +44,14 @@ private function __construct(ApplicableOperation $operation, iterable $elements $this->iterator = new InternalIterator(elements: $elements, operation: $operation); } - public static function createFrom(iterable $elements): Collectible + public static function createFrom(iterable $elements): static { - return new Collection(operation: Create::fromEmpty(), elements: $elements); + return new static(operation: Create::fromEmpty(), elements: $elements); } - public static function createFromEmpty(): Collectible + public static function createFromEmpty(): static { - return new Collection(operation: Create::fromEmpty()); + return new static(operation: Create::fromEmpty()); } public function add(mixed ...$elements): Collectible @@ -139,6 +139,11 @@ public function removeAll(?Closure $filter = null): Collectible return $this; } + public function reduce(Closure $aggregator, mixed $initial): mixed + { + return Reduce::from(elements: $this->iterator)->execute(aggregator: $aggregator, initial: $initial); + } + public function sort(Order $order = Order::ASCENDING_KEY, ?Closure $predicate = null): Collectible { $operation = Sort::from(order: $order, predicate: $predicate); diff --git a/src/Internal/Operations/Aggregate/Reduce.php b/src/Internal/Operations/Aggregate/Reduce.php new file mode 100644 index 0000000..e483859 --- /dev/null +++ b/src/Internal/Operations/Aggregate/Reduce.php @@ -0,0 +1,31 @@ +elements as $element) { + $carry = $aggregator($carry, $element); + } + + return $carry; + } +} diff --git a/src/Internal/Operations/Filter/Filter.php b/src/Internal/Operations/Filter/Filter.php index f55b390..e311885 100644 --- a/src/Internal/Operations/Filter/Filter.php +++ b/src/Internal/Operations/Filter/Filter.php @@ -25,12 +25,14 @@ public static function from(Closure ...$predicates): Filter public function apply(iterable $elements): Generator { $predicate = $this->predicates - ? fn(mixed $value, mixed $key): bool => array_reduce( - $this->predicates, - fn(bool $isValid, Closure $predicate): bool => $isValid && $predicate($value, $key), - true - ) - : fn(mixed $value): bool => (bool)$value; + ? function (mixed $value, mixed $key): bool { + return array_reduce( + $this->predicates, + static fn(bool $isValid, Closure $predicate): bool => $isValid && $predicate($value, $key), + true + ); + } + : static fn(mixed $value): bool => (bool)$value; foreach ($elements as $key => $value) { if ($predicate($value, $key)) { diff --git a/src/Internal/Operations/Transform/Each.php b/src/Internal/Operations/Transform/Each.php index 4f40e10..9dc3c47 100644 --- a/src/Internal/Operations/Transform/Each.php +++ b/src/Internal/Operations/Transform/Each.php @@ -23,14 +23,14 @@ public static function from(Closure ...$actions): Each public function execute(iterable $elements): void { - $runActions = function () use ($elements): void { + $runActions = static function ($actions) use ($elements): void { foreach ($elements as $key => $value) { - foreach ($this->actions as $action) { + foreach ($actions as $action) { $action($value, $key); } } }; - $runActions(); + $runActions($this->actions); } } diff --git a/tests/CollectionTest.php b/tests/CollectionTest.php index 20a9de7..3087dfc 100644 --- a/tests/CollectionTest.php +++ b/tests/CollectionTest.php @@ -19,8 +19,8 @@ public function testAddMapSortAndToJson(): void /** @When adding, mapping to Amount objects, sorting by value, and converting to JSON */ $collection - ->add(4) - ->map(fn(int $value): Amount => new Amount(value: $value, currency: Currency::USD)) + ->add(elements: 4) + ->map(transformations: fn(int $value): Amount => new Amount(value: $value, currency: Currency::USD)) ->sort( order: Order::ASCENDING_VALUE, predicate: fn(Amount $first, Amount $second): int => $first->value <=> $second->value @@ -45,8 +45,8 @@ public function testFilterMapSortAndCount(): void * sorting by value in ascending order, and counting */ $collection - ->filter(fn(float $value): bool => $value > 5) - ->map(fn(float $value): Amount => new Amount(value: $value, currency: Currency::BRL)) + ->filter(predicate: fn(float $value): bool => $value > 5) + ->map(transformations: fn(float $value): Amount => new Amount(value: $value, currency: Currency::BRL)) ->sort( order: Order::ASCENDING_VALUE, predicate: fn(Amount $first, Amount $second): int => $first->value <=> $second->value @@ -117,7 +117,7 @@ public function testAddFilterRemoveSortAndToArray(): void */ $collection ->add(2, 6, 10, 5) - ->filter(fn(int $value): bool => $value > 5) + ->filter(predicate: fn(int $value): bool => $value > 5) ->remove(element: 10) ->sort( order: Order::DESCENDING_VALUE, @@ -129,6 +129,50 @@ public function testAddFilterRemoveSortAndToArray(): void self::assertSame(1, $collection->count()); } + public function testPerformanceAndMemoryArrayVsCollection(): void + { + /** @Given a large dataset */ + $elements = range(1, 50000); + + /** @When performing operations with an array */ + $startArrayTime = microtime(true); + $startArrayMemory = memory_get_usage(); + + $array = $elements; + $array = array_map(fn(int $value): Amount => new Amount(value: $value, currency: Currency::USD), $array); + usort($array, fn(Amount $first, Amount $second): int => $first->value <=> $second->value); + + $endArrayTime = microtime(true); + $endArrayMemory = memory_get_usage(); + + /** @Then assert that the array operations are performed */ + $arrayExecutionTime = $endArrayTime - $startArrayTime; + $arrayMemoryUsage = $endArrayMemory - $startArrayMemory; + + /** @When performing operations with Collection using Generator */ + $startCollectionTime = microtime(true); + $startCollectionMemory = memory_get_usage(); + + $collection = Collection::createFrom(elements: $elements); + $collection + ->map(transformations: fn(int $value): Amount => new Amount(value: $value, currency: Currency::USD)) + ->sort( + order: Order::ASCENDING_VALUE, + predicate: fn(Amount $first, Amount $second): int => $first->value <=> $second->value + ); + + $endCollectionTime = microtime(true); + $endCollectionMemory = memory_get_usage(); + + /** @Then assert that the collection operations are performed */ + $collectionExecutionTime = $endCollectionTime - $startCollectionTime; + $collectionMemoryUsage = $endCollectionMemory - $startCollectionMemory; + + /** @Then assert that the collection is faster and uses less memory */ + self::assertLessThan($arrayExecutionTime, $collectionExecutionTime, 'Collection is slower than array.'); + self::assertLessThan($arrayMemoryUsage, $collectionMemoryUsage, 'Collection uses more memory than array.'); + } + public function testIteratorShouldBeReusedIfNoModification(): void { /** @Given a collection with elements */ diff --git a/tests/Models/InvoiceSummaries.php b/tests/Models/InvoiceSummaries.php index 9d049d4..567f51e 100644 --- a/tests/Models/InvoiceSummaries.php +++ b/tests/Models/InvoiceSummaries.php @@ -8,4 +8,13 @@ final class InvoiceSummaries extends Collection { + public function sumByCustomer(string $customer): float + { + return $this + ->filter(predicates: fn(InvoiceSummary $summary): bool => $summary->customer === $customer) + ->reduce( + aggregator: fn(float $carry, InvoiceSummary $summary): float => $carry + $summary->amount, + initial: 0.0 + ); + } } diff --git a/tests/Models/InvoiceSummary.php b/tests/Models/InvoiceSummary.php index feac2ef..cb7311a 100644 --- a/tests/Models/InvoiceSummary.php +++ b/tests/Models/InvoiceSummary.php @@ -6,7 +6,7 @@ final class InvoiceSummary { - public function __construct(public string $id, public float $amount) + public function __construct(public float $amount, public string $customer) { } } diff --git a/tests/Operations/Aggregate/CollectionReduceOperationTest.php b/tests/Operations/Aggregate/CollectionReduceOperationTest.php new file mode 100644 index 0000000..f50139b --- /dev/null +++ b/tests/Operations/Aggregate/CollectionReduceOperationTest.php @@ -0,0 +1,126 @@ +sumByCustomer(customer: 'Customer A'); + + /** @Then the total amount for 'Customer A' should be 250.5 */ + self::assertSame(250.5, $actual); + } + + public function testReduceSumOfNumbers(): void + { + /** @Given a collection of numbers */ + $numbers = InvoiceSummaries::createFrom(elements: [1, 2, 3, 4, 5]); + + /** @When reducing the collection to a sum */ + $actual = $numbers->reduce( + aggregator: fn(int $carry, int $number): int => $carry + $number, + initial: 0 + ); + + /** @Then the sum should be correct */ + self::assertSame(15, $actual); + } + + public function testReduceProductOfNumbers(): void + { + /** @Given a collection of numbers */ + $numbers = InvoiceSummaries::createFrom(elements: [1, 2, 3, 4]); + + /** @When reducing the collection to a product */ + $actual = $numbers->reduce( + aggregator: fn(int $carry, int $number): int => $carry * $number, + initial: 1 + ); + + /** @Then the product should be correct */ + self::assertSame(24, $actual); + } + + public function testReduceWhenNoMatchFound(): void + { + /** @Given a collection of invoice summaries */ + $summaries = InvoiceSummaries::createFrom(elements: [ + new InvoiceSummary(amount: 100.0, customer: 'Customer A'), + new InvoiceSummary(amount: 150.5, customer: 'Customer A'), + new InvoiceSummary(amount: 200.75, customer: 'Customer B') + ]); + + /** @When reducing the collection for a customer with no match */ + $actual = $summaries + ->filter(predicates: fn(InvoiceSummary $summary): bool => $summary->customer === 'Customer C') + ->reduce(aggregator: fn(float $carry, InvoiceSummary $summary): float => $carry + $summary->amount, + initial: 0.0); + + /** @Then the total amount for 'Customer C' should be zero */ + self::assertSame(0.0, $actual); + } + + public function testReduceWithMixedDataTypes(): void + { + /** @Given a collection with mixed data types */ + $mixedData = InvoiceSummaries::createFrom(elements: [1, 'string', 3.14, true]); + + /** @When reducing the collection by concatenating values as strings */ + $actual = $mixedData->reduce( + aggregator: fn(string $carry, mixed $value): string => $carry . $value, + initial: '' + ); + + /** @Then the concatenated string should be correct */ + self::assertSame('1string3.141', $actual); + } + + public function testReduceSumForEmptyCollection(): void + { + /** @Given an empty collection of invoice summaries */ + $summaries = InvoiceSummaries::createFrom(elements: []); + + /** @When reducing an empty collection */ + $actual = $summaries->reduce( + aggregator: fn(float $carry, InvoiceSummary $summary): float => $carry + $summary->amount, + initial: 0.0 + ); + + /** @Then the total amount should be zero */ + self::assertSame(0.0, $actual); + } + + public function testReduceWithDifferentInitialValue(): void + { + /** @Given a collection of invoice summaries */ + $summaries = InvoiceSummaries::createFrom(elements: [ + new InvoiceSummary(amount: 100.0, customer: 'Customer A'), + new InvoiceSummary(amount: 150.5, customer: 'Customer A'), + new InvoiceSummary(amount: 200.75, customer: 'Customer B') + ]); + + /** @When summing the amounts for customer 'Customer A' with an initial value of 50 */ + $actual = $summaries + ->filter(predicates: fn(InvoiceSummary $summary): bool => $summary->customer === 'Customer A') + ->reduce(aggregator: fn(float $carry, InvoiceSummary $summary): float => $carry + $summary->amount, + initial: 50.0); + + /** @Then the total amount for 'Customer A' should be 300.5 */ + self::assertSame(300.5, $actual); + } +} diff --git a/tests/Operations/Transform/CollectionEachOperationTest.php b/tests/Operations/Transform/CollectionEachOperationTest.php index 3df9f9a..be175d2 100644 --- a/tests/Operations/Transform/CollectionEachOperationTest.php +++ b/tests/Operations/Transform/CollectionEachOperationTest.php @@ -27,16 +27,16 @@ public function testTransformCollection(): void /** @When mapping specific attributes from invoices to invoice summaries */ $invoices->each(function (Invoice $invoice) use ($summaries): void { - $summaries->add(new InvoiceSummary(id: $invoice->id, amount: $invoice->amount)); + $summaries->add(new InvoiceSummary(amount: $invoice->amount, customer: $invoice->customer)); }); /** @Then the invoice summaries should contain the mapped data */ self::assertCount(3, $summaries); $expected = [ - ['id' => 'INV001', 'amount' => 100.0], - ['id' => 'INV002', 'amount' => 150.5], - ['id' => 'INV003', 'amount' => 200.75] + ['amount' => 100.0, 'customer' => 'Customer A'], + ['amount' => 150.5, 'customer' => 'Customer B'], + ['amount' => 200.75, 'customer' => 'Customer C'] ]; self::assertEquals($expected, $summaries->toArray());