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());