diff --git a/.gitattributes b/.gitattributes
index 22aac70..8c85471 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -4,7 +4,6 @@
/LICENSE export-ignore
/Makefile export-ignore
/README.md export-ignore
-/phpmd.xml export-ignore
/phpunit.xml export-ignore
/phpstan.neon.dist export-ignore
/infection.json.dist export-ignore
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index ddd0e41..e898dc1 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -7,12 +7,42 @@ permissions:
contents: read
env:
- PHP_VERSION: '8.3'
+ PHP_VERSION: '8.5'
jobs:
+ build:
+ name: Build
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+
+ - name: Configure PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ env.PHP_VERSION }}
+ extensions: bcmath
+ tools: composer:2
+
+ - name: Validate composer.json
+ run: composer validate --no-interaction
+
+ - name: Install dependencies
+ run: composer install --no-progress --optimize-autoloader --prefer-dist --no-interaction
+
+ - name: Upload vendor and composer.lock as artifact
+ uses: actions/upload-artifact@v6
+ with:
+ name: vendor-artifact
+ path: |
+ vendor
+ composer.lock
+
auto-review:
name: Auto review
runs-on: ubuntu-latest
+ needs: build
steps:
- name: Checkout
@@ -22,9 +52,14 @@ jobs:
uses: shivammathur/setup-php@v2
with:
php-version: ${{ env.PHP_VERSION }}
+ extensions: bcmath
+ tools: composer:2
- - name: Install dependencies
- run: composer update --no-progress --optimize-autoloader
+ - name: Download vendor artifact from build
+ uses: actions/download-artifact@v7
+ with:
+ name: vendor-artifact
+ path: .
- name: Run review
run: composer review
@@ -32,6 +67,7 @@ jobs:
tests:
name: Tests
runs-on: ubuntu-latest
+ needs: auto-review
steps:
- name: Checkout
@@ -41,24 +77,14 @@ jobs:
uses: shivammathur/setup-php@v2
with:
php-version: ${{ env.PHP_VERSION }}
+ extensions: bcmath
+ tools: composer:2
- - name: Install dependencies
- run: composer update --no-progress --optimize-autoloader
-
- - name: Clean up Docker
- run: docker system prune -f
-
- - name: Create Docker network
- run: docker network create tiny-blocks
-
- - name: Create Docker volume for migrations
- run: docker volume create test-adm-migrations
+ - name: Download vendor artifact from build
+ uses: actions/download-artifact@v7
+ with:
+ name: vendor-artifact
+ path: .
- name: Run tests
- run: |
- docker run --network=tiny-blocks \
- -v ${PWD}:/app \
- -v ${PWD}/tests/Integration/Database/Migrations:/test-adm-migrations \
- -v /var/run/docker.sock:/var/run/docker.sock \
- -w /app \
- gustavofreze/php:${{ env.PHP_VERSION }} bash -c "composer tests"
+ run: composer tests
diff --git a/Makefile b/Makefile
index 96ccd27..ef9a884 100644
--- a/Makefile
+++ b/Makefile
@@ -1,9 +1,4 @@
-ifeq ($(OS),Windows_NT)
- PWD := $(shell cd)
-else
- PWD := $(shell pwd -L)
-endif
-
+PWD := $(CURDIR)
ARCH := $(shell uname -m)
PLATFORM :=
@@ -11,28 +6,63 @@ ifeq ($(ARCH),arm64)
PLATFORM := --platform=linux/amd64
endif
-DOCKER_RUN = docker run ${PLATFORM} --rm -it --net=host -v ${PWD}:/app -w /app gustavofreze/php:8.3
+DOCKER_RUN = docker run ${PLATFORM} --rm -it --net=host -v ${PWD}:/app -w /app gustavofreze/php:8.5-alpine
-.PHONY: configure test test-file test-no-coverage review show-reports clean
+RESET := \033[0m
+GREEN := \033[0;32m
+YELLOW := \033[0;33m
-configure:
+.DEFAULT_GOAL := help
+
+.PHONY: configure
+configure: ## Configure development environment
@${DOCKER_RUN} composer update --optimize-autoloader
-test:
+.PHONY: test
+test: ## Run all tests with coverage
@${DOCKER_RUN} composer tests
-test-file:
+.PHONY: test-file
+test-file: ## Run tests for a specific file (usage: make test-file FILE=path/to/file)
@${DOCKER_RUN} composer test-file ${FILE}
-test-no-coverage:
+.PHONY: test-no-coverage
+test-no-coverage: ## Run all tests without coverage
@${DOCKER_RUN} composer tests-no-coverage
-review:
+.PHONY: review
+review: ## Run static code analysis
@${DOCKER_RUN} composer review
-show-reports:
+.PHONY: show-reports
+show-reports: ## Open static analysis reports (e.g., coverage, lints) in the browser
@sensible-browser report/coverage/coverage-html/index.html report/coverage/mutation-report.html
-clean:
+.PHONY: clean
+clean: ## Remove dependencies and generated artifacts
@sudo chown -R ${USER}:${USER} ${PWD}
@rm -rf report vendor .phpunit.cache *.lock
+
+.PHONY: help
+help: ## Display this help message
+ @echo "Usage: make [target]"
+ @echo ""
+ @echo "$$(printf '$(GREEN)')Setup$$(printf '$(RESET)')"
+ @grep -E '^(configure):.*?## .*$$' $(MAKEFILE_LIST) \
+ | awk 'BEGIN {FS = ":.*? ## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}'
+ @echo ""
+ @echo "$$(printf '$(GREEN)')Testing$$(printf '$(RESET)')"
+ @grep -E '^(test|test-file|test-no-coverage):.*?## .*$$' $(MAKEFILE_LIST) \
+ | awk 'BEGIN {FS = ":.*?## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}'
+ @echo ""
+ @echo "$$(printf '$(GREEN)')Quality$$(printf '$(RESET)')"
+ @grep -E '^(review):.*?## .*$$' $(MAKEFILE_LIST) \
+ | awk 'BEGIN {FS = ":.*?## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}'
+ @echo ""
+ @echo "$$(printf '$(GREEN)')Reports$$(printf '$(RESET)')"
+ @grep -E '^(show-reports):.*?## .*$$' $(MAKEFILE_LIST) \
+ | awk 'BEGIN {FS = ":.*?## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}'
+ @echo ""
+ @echo "$$(printf '$(GREEN)')Cleanup$$(printf '$(RESET)')"
+ @grep -E '^(clean):.*?## .*$$' $(MAKEFILE_LIST) \
+ | awk 'BEGIN {FS = ":.*?## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}'
diff --git a/README.md b/README.md
index ebc45e2..5d60447 100644
--- a/README.md
+++ b/README.md
@@ -68,9 +68,9 @@ use TinyBlocks\Mapper\KeyPreservation;
$collection = Collection::createFrom(elements: [1, 2, 3, 4, 5])
->add(elements: [6, 7])
- ->filter(predicates: fn(int $value): bool => $value > 3)
+ ->filter(predicates: static fn(int $value): bool => $value > 3)
->sort(order: Order::ASCENDING_VALUE)
- ->map(transformations: fn(int $value): int => $value * 2)
+ ->map(transformations: static fn(int $value): int => $value * 2)
->toArray(keyPreservation: KeyPreservation::DISCARD);
# Output: [8, 10, 12, 14]
@@ -193,7 +193,7 @@ elements, or finding elements that match a specific condition.
- `findBy`: Finds the first element that matches one or more predicates.
```php
- $collection->findBy(predicates: fn(CryptoCurrency $crypto): bool => $crypto->symbol === 'ETH');
+ $collection->findBy(predicates: static fn(CryptoCurrency $crypto): bool => $crypto->symbol === 'ETH');
```
@@ -261,7 +261,7 @@ combining elements.
This method is helpful for accumulating results, like summing or concatenating values.
```php
- $collection->reduce(aggregator: fn(float $carry, float $amount): float => $carry + $amount, initial: 0.0)
+ $collection->reduce(aggregator: static fn(float $carry, float $amount): float => $carry + $amount, initial: 0.0)
```
@@ -276,7 +276,7 @@ These methods allow the Collection's elements to be transformed or converted int
The method is helpful for performing side effects, such as logging or adding elements to another collection.
```php
- $collection->each(actions: fn(Invoice $invoice): void => $collectionB->add(elements: new InvoiceSummary(amount: $invoice->amount, customer: $invoice->customer)));
+ $collection->each(actions: static fn(Invoice $invoice): void => $collectionB->add(elements: new InvoiceSummary(amount: $invoice->amount, customer: $invoice->customer)));
```
#### Grouping elements
@@ -284,7 +284,7 @@ These methods allow the Collection's elements to be transformed or converted int
- `groupBy`: Groups the elements in the Collection based on the provided grouping criterion.
```php
- $collection->groupBy(grouping: fn(Amount $amount): string => $amount->currency->name);
+ $collection->groupBy(grouping: static fn(Amount $amount): string => $amount->currency->name);
```
#### Mapping elements
@@ -293,7 +293,7 @@ These methods allow the Collection's elements to be transformed or converted int
elements.
```php
- $collection->map(transformations: fn(int $value): int => $value * 2);
+ $collection->map(transformations: static fn(int $value): int => $value * 2);
```
#### Flattening elements
diff --git a/composer.json b/composer.json
index 72e637b..1257c8f 100644
--- a/composer.json
+++ b/composer.json
@@ -44,27 +44,24 @@
}
},
"require": {
- "php": "^8.3",
- "tiny-blocks/mapper": "^1.2"
+ "php": "^8.5",
+ "tiny-blocks/mapper": "1.2.*"
},
"require-dev": {
- "phpmd/phpmd": "^2.15",
- "phpunit/phpunit": "^12.1",
+ "phpunit/phpunit": "^11.5",
"phpstan/phpstan": "^2.1",
"infection/infection": "^0.32",
"squizlabs/php_codesniffer": "^3.13"
},
"scripts": {
- "test": "phpunit -d memory_limit=2G --configuration phpunit.xml tests",
- "phpcs": "phpcs --standard=PSR12 --extensions=php ./src",
- "phpmd": "phpmd ./src text phpmd.xml --suffixes php --ignore-violations-on-exit",
- "phpstan": "phpstan analyse -c phpstan.neon.dist --quiet --no-progress",
- "test-file": "phpunit --configuration phpunit.xml --no-coverage --filter",
- "mutation-test": "infection --threads=max --logger-html=report/coverage/mutation-report.html --coverage=report/coverage",
- "test-no-coverage": "phpunit --configuration phpunit.xml --no-coverage tests",
+ "test": "php -d memory_limit=2G ./vendor/bin/phpunit --configuration phpunit.xml tests",
+ "phpcs": "php ./vendor/bin/phpcs --standard=PSR12 --extensions=php ./src",
+ "phpstan": "php ./vendor/bin/phpstan analyse -c phpstan.neon.dist --quiet --no-progress",
+ "test-file": "php ./vendor/bin/phpunit --configuration phpunit.xml --no-coverage --filter",
+ "mutation-test": "php ./vendor/bin/infection --threads=max --logger-html=report/coverage/mutation-report.html --coverage=report/coverage",
+ "test-no-coverage": "php ./vendor/bin/phpunit --configuration phpunit.xml --no-coverage tests",
"review": [
"@phpcs",
- "@phpmd",
"@phpstan"
],
"tests": [
diff --git a/phpmd.xml b/phpmd.xml
deleted file mode 100644
index bb59312..0000000
--- a/phpmd.xml
+++ /dev/null
@@ -1,59 +0,0 @@
-
-
- PHPMD Custom rules
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/phpstan.neon.dist b/phpstan.neon.dist
index 718ae62..90bbd03 100644
--- a/phpstan.neon.dist
+++ b/phpstan.neon.dist
@@ -7,4 +7,5 @@ parameters:
- '#Unsafe usage of new static#'
- '#does not specify its types#'
- '#type specified in iterable type#'
+ - '#Generator expects key type#'
reportUnmatchedIgnoredErrors: false
diff --git a/src/Internal/Operations/Filter/Filter.php b/src/Internal/Operations/Filter/Filter.php
index 6bae486..9dd4b8e 100644
--- a/src/Internal/Operations/Filter/Filter.php
+++ b/src/Internal/Operations/Filter/Filter.php
@@ -12,9 +12,24 @@
{
private array $predicates;
+ private Closure $compiledPredicate;
+
private function __construct(?Closure ...$predicates)
{
$this->predicates = array_filter($predicates);
+
+ $buildCompositePredicate = static fn(array $predicates): Closure => static fn(
+ mixed $value,
+ mixed $key
+ ): bool => array_all(
+ $predicates,
+ static fn(Closure $predicate): bool => $predicate($value, $key)
+ );
+
+ $this->compiledPredicate = match (count($this->predicates)) {
+ 0 => static fn(mixed $value, mixed $key): bool => (bool)$value,
+ default => $buildCompositePredicate($this->predicates)
+ };
}
public static function from(?Closure ...$predicates): Filter
@@ -24,16 +39,7 @@ public static function from(?Closure ...$predicates): Filter
public function apply(iterable $elements): Generator
{
- $predicate = $this->predicates
- ? function (mixed $value, mixed $key): bool {
- foreach ($this->predicates as $predicate) {
- if (!$predicate($value, $key)) {
- return false;
- }
- }
- return true;
- }
- : static fn(mixed $value): bool => (bool)$value;
+ $predicate = $this->compiledPredicate;
foreach ($elements as $key => $value) {
if ($predicate($value, $key)) {
diff --git a/src/Internal/Operations/Retrieve/Find.php b/src/Internal/Operations/Retrieve/Find.php
index f004d92..07c154b 100644
--- a/src/Internal/Operations/Retrieve/Find.php
+++ b/src/Internal/Operations/Retrieve/Find.php
@@ -21,10 +21,8 @@ public static function from(iterable $elements): Find
public function firstMatchingElement(Closure ...$predicates): mixed
{
foreach ($this->elements as $element) {
- foreach ($predicates as $predicate) {
- if ($predicate($element)) {
- return $element;
- }
+ if (array_any($predicates, static fn(Closure $predicate): bool => $predicate($element))) {
+ return $element;
}
}
diff --git a/src/Internal/Operations/Retrieve/First.php b/src/Internal/Operations/Retrieve/First.php
index 2592c12..7eaf3cf 100644
--- a/src/Internal/Operations/Retrieve/First.php
+++ b/src/Internal/Operations/Retrieve/First.php
@@ -19,6 +19,10 @@ public static function from(iterable $elements): First
public function element(mixed $defaultValueIfNotFound = null): mixed
{
+ if (is_array($this->elements)) {
+ return array_first($this->elements) ?? $defaultValueIfNotFound;
+ }
+
foreach ($this->elements as $element) {
return $element;
}
diff --git a/src/Internal/Operations/Retrieve/Last.php b/src/Internal/Operations/Retrieve/Last.php
index 764a4b8..7046f5b 100644
--- a/src/Internal/Operations/Retrieve/Last.php
+++ b/src/Internal/Operations/Retrieve/Last.php
@@ -19,6 +19,10 @@ public static function from(iterable $elements): Last
public function element(mixed $defaultValueIfNotFound = null): mixed
{
+ if (is_array($this->elements)) {
+ return array_last($this->elements) ?? $defaultValueIfNotFound;
+ }
+
$lastElement = $defaultValueIfNotFound;
foreach ($this->elements as $element) {
diff --git a/src/Internal/Operations/Retrieve/Slice.php b/src/Internal/Operations/Retrieve/Slice.php
index 9121885..49ddcaa 100644
--- a/src/Internal/Operations/Retrieve/Slice.php
+++ b/src/Internal/Operations/Retrieve/Slice.php
@@ -19,6 +19,34 @@ public static function from(int $index, int $length): Slice
}
public function apply(iterable $elements): Generator
+ {
+ if ($this->length === 0) {
+ return;
+ }
+
+ if ($this->length < -1) {
+ yield from $this->applyWithBufferedSlice(elements: $elements);
+ return;
+ }
+
+ $currentIndex = 0;
+ $yieldedCount = 0;
+
+ foreach ($elements as $key => $value) {
+ if ($currentIndex++ < $this->index) {
+ continue;
+ }
+
+ yield $key => $value;
+ $yieldedCount++;
+
+ if ($this->length !== -1 && $yieldedCount >= $this->length) {
+ return;
+ }
+ }
+ }
+
+ private function applyWithBufferedSlice(iterable $elements): Generator
{
$collected = [];
$currentIndex = 0;
@@ -31,9 +59,7 @@ public function apply(iterable $elements): Generator
$collected[] = [$key, $value];
}
- if ($this->length !== -1) {
- $collected = array_slice($collected, 0, $this->length);
- }
+ $collected = array_slice($collected, 0, $this->length);
foreach ($collected as [$key, $value]) {
yield $key => $value;
diff --git a/tests/CollectionPerformanceTest.php b/tests/CollectionPerformanceTest.php
index d3978f0..5a8ca66 100644
--- a/tests/CollectionPerformanceTest.php
+++ b/tests/CollectionPerformanceTest.php
@@ -4,65 +4,161 @@
namespace TinyBlocks\Collection;
+use Generator;
use PHPUnit\Framework\TestCase;
use TinyBlocks\Collection\Models\Amount;
use TinyBlocks\Collection\Models\Currency;
final class CollectionPerformanceTest extends TestCase
{
- public function testArrayVsCollectionPerformanceAndMemoryComparison(): void
+ public function testArrayVsCollectionWithSortProduceSameResults(): void
{
/** @Given a large dataset with 10 thousand elements */
$elements = range(1, 10_000);
/** @When performing operations with an array */
- $startArrayTime = microtime(true);
- $startArrayMemory = memory_get_usage();
-
- /** @And map elements to Amount objects and sort them in ascending order using array functions */
- $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);
-
- /** @And end the time and memory measurement for the array operations */
- $endArrayTime = microtime(true);
- $endArrayMemory = memory_get_usage();
-
- /** @Then assert that the array operations are performed and measure performance */
- $arrayExecutionTime = $endArrayTime - $startArrayTime;
- $arrayMemoryUsage = $endArrayMemory - $startArrayMemory;
-
- /** @When performing operations with Collection using Generator */
- $startCollectionTime = microtime(true);
- $startCollectionMemory = memory_get_usage();
-
- /** @And map elements to Amount objects and sort them in ascending order using Collection */
- $collection = Collection::createFrom(elements: $elements);
- $collection
- ->map(transformations: fn(int $value): Amount => new Amount(value: $value, currency: Currency::USD))
+ $array = array_map(
+ static fn(int $value): Amount => new Amount(value: $value, currency: Currency::USD),
+ $elements
+ );
+ usort($array, static fn(Amount $first, Amount $second): int => $first->value <=> $second->value);
+
+ /** @And performing the same operations with Collection */
+ $collection = Collection::createFrom(elements: $elements)
+ ->map(static 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
+ predicate: static fn(Amount $first, Amount $second): int => $first->value <=> $second->value
);
- /** @And end the time and memory measurement for the Collection operations */
- $endCollectionTime = microtime(true);
- $endCollectionMemory = memory_get_usage();
+ /** @Then assert that both approaches produce the same results */
+ self::assertEquals($array[0]->value, $collection->first()->value);
+ self::assertEquals($array[array_key_last($array)]->value, $collection->last()->value);
+ }
+
+ public function testCollectionIsEfficientForFirstElementRetrieval(): void
+ {
+ /** @Given a large dataset with 10 million elements as a generator */
+ $createGenerator = static fn(): Generator => (static function (): Generator {
+ for ($index = 1; $index <= 10_000_000; $index++) {
+ yield $index;
+ }
+ })();
+
+ /** @When retrieving the first element matching a condition near the beginning */
+ $this->forceGarbageCollection();
+ $startTime = hrtime(true);
+ $startMemory = memory_get_usage(true);
+
+ /** @And filter for elements greater than 100 and get the first one (match at position 101) */
+ $firstElement = Collection::createFrom(elements: $createGenerator())
+ ->filter(static fn(int $value): bool => $value > 100)
+ ->first();
- /** @Then assert that the Collection operations are performed and measure performance */
- $collectionExecutionTime = $endCollectionTime - $startCollectionTime;
- $collectionMemoryUsage = $endCollectionMemory - $startCollectionMemory;
+ /** @And end the time and memory measurement */
+ $executionTimeInMs = (hrtime(true) - $startTime) / 1_000_000;
+ $memoryUsageInMB = (memory_get_usage(true) - $startMemory) / 1024 / 1024;
- /** @And assert that the Collection is faster and uses less memory than the array */
- self::assertLessThan($arrayExecutionTime, $collectionExecutionTime, 'Collection is slower than array.');
- self::assertLessThan($arrayMemoryUsage, $collectionMemoryUsage, 'Collection uses more memory than array.');
+ /** @Then assert that the first matching element is found */
+ self::assertSame(101, $firstElement);
+
+ /** @And assert that execution time is minimal due to early termination */
+ self::assertLessThan(
+ 50.0,
+ $executionTimeInMs,
+ sprintf('Execution time %.2fms exceeded 50ms limit', $executionTimeInMs)
+ );
+
+ /** @And assert that memory usage is minimal due to not materializing all elements */
+ self::assertLessThan(
+ 2.0,
+ $memoryUsageInMB,
+ sprintf('Memory usage %.2fMB exceeded 2MB limit', $memoryUsageInMB)
+ );
+ }
+
+ public function testLazyOperationsDoNotMaterializeEntireCollection(): void
+ {
+ /** @Given a generator that would use massive memory if fully materialized */
+ $createLargeGenerator = static fn(): Generator => (static function (): Generator {
+ for ($index = 1; $index <= 10_000_000; $index++) {
+ yield $index;
+ }
+ })();
+
+ /** @When applying multiple transformations and getting only the first element */
+ $this->forceGarbageCollection();
+ $startMemory = memory_get_usage(true);
+
+ /** @And chain filter and map operations */
+ $result = Collection::createFrom(elements: $createLargeGenerator())
+ ->filter(static fn(int $value): bool => $value % 2 === 0)
+ ->map(static fn(int $value): int => $value * 10)
+ ->filter(static fn(int $value): bool => $value > 100)
+ ->first();
+
+ /** @And measure memory after operation */
+ $memoryUsageInMB = (memory_get_usage(true) - $startMemory) / 1024 / 1024;
+
+ /** @Then assert that the correct result is returned */
+ self::assertSame(120, $result);
+
+ /** @And assert that memory usage is minimal (not 10 million integers in memory) */
+ self::assertLessThan(
+ 10.0,
+ $memoryUsageInMB,
+ sprintf(
+ 'Memory usage %.2fMB is too high - collection may be materializing unnecessarily',
+ $memoryUsageInMB
+ )
+ );
+ }
+
+ public function testCollectionFindByIsEfficientWithEarlyTermination(): void
+ {
+ /** @Given a large dataset with 1 million elements as a generator */
+ $createGenerator = static fn(): Generator => (static function (): Generator {
+ for ($index = 1; $index <= 1_000_000; $index++) {
+ yield new Amount(value: $index, currency: Currency::USD);
+ }
+ })();
+
+ /** @When finding the first element with value 100 using Collection */
+ $this->forceGarbageCollection();
+ $startTime = hrtime(true);
+ $startMemory = memory_get_usage(true);
+
+ /** @And use findBy to locate the element */
+ $foundElement = Collection::createFrom(elements: $createGenerator())
+ ->findBy(static fn(Amount $amount): bool => $amount->value == 100.0);
+
+ /** @And end the time and memory measurement */
+ $executionTimeInMs = (hrtime(true) - $startTime) / 1_000_000;
+ $memoryUsageInMB = (memory_get_usage(true) - $startMemory) / 1024 / 1024;
+
+ /** @Then assert that the correct element is found */
+ self::assertSame(100.0, $foundElement->value);
+
+ /** @And assert that execution time is minimal due to early termination */
+ self::assertLessThan(
+ 100.0,
+ $executionTimeInMs,
+ sprintf('Execution time %.2fms exceeded 100ms limit', $executionTimeInMs)
+ );
+
+ /** @And assert that memory usage is minimal */
+ self::assertLessThan(
+ 2.0,
+ $memoryUsageInMB,
+ sprintf('Memory usage %.2fMB exceeded 2MB limit', $memoryUsageInMB)
+ );
}
public function testChainedOperationsPerformanceAndMemoryWithCollection(): void
{
/** @Given a large collection of Amount objects containing 100 thousand elements */
$collection = Collection::createFrom(
- elements: (function () {
+ elements: (static function (): Generator {
foreach (range(1, 100_000) as $value) {
yield new Amount(value: $value, currency: Currency::USD);
}
@@ -70,8 +166,9 @@ public function testChainedOperationsPerformanceAndMemoryWithCollection(): void
);
/** @And start measuring time and memory usage */
- $startTime = microtime(true);
- $startMemory = memory_get_usage();
+ $this->forceGarbageCollection();
+ $startTime = hrtime(true);
+ $startMemory = memory_get_usage(true);
/**
* @When performing the following chained operations:
@@ -85,38 +182,114 @@ public function testChainedOperationsPerformanceAndMemoryWithCollection(): void
* mapping to convert the currency from USD to BRL and adjusting the value by a factor of 5.5,
* sorting the collection in descending order by value.
*/
- $collection
- ->filter(predicates: fn(Amount $amount, int $index): bool => $index < 50_000)
- ->map(transformations: fn(Amount $amount): Amount => new Amount(
+ $result = $collection
+ ->filter(static fn(Amount $amount, int $index): bool => $index < 50_000)
+ ->map(static fn(Amount $amount): Amount => new Amount(
value: $amount->value * 2,
currency: $amount->currency
))
- ->filter(predicates: fn(Amount $amount, int $index): bool => $index < 45_000)
- ->filter(predicates: fn(Amount $amount, int $index): bool => $index < 35_000)
- ->map(transformations: fn(Amount $amount): Amount => new Amount(
+ ->filter(static fn(Amount $amount, int $index): bool => $index < 45_000)
+ ->filter(static fn(Amount $amount, int $index): bool => $index < 35_000)
+ ->map(static fn(Amount $amount): Amount => new Amount(
value: $amount->value * 2,
currency: $amount->currency
))
- ->filter(predicates: fn(Amount $amount, int $index): bool => $index < 30_000)
- ->filter(predicates: fn(Amount $amount, int $index): bool => $index < 10_000)
- ->map(transformations: fn(Amount $amount): Amount => new Amount(
+ ->filter(static fn(Amount $amount, int $index): bool => $index < 30_000)
+ ->filter(static fn(Amount $amount, int $index): bool => $index < 10_000)
+ ->map(static fn(Amount $amount): Amount => new Amount(
value: $amount->value * 5.5,
currency: Currency::BRL
))
->sort(order: Order::DESCENDING_VALUE);
- /** @Then verify the value of the first element in the sorted collection */
- self::assertSame(220_000.0, $collection->first()->value);
+ /** @And force full evaluation by getting first element */
+ $firstElement = $result->first();
/** @And end measuring time and memory usage */
- $endTime = microtime(true);
- $endMemory = memory_get_usage();
+ $executionTimeInSeconds = (hrtime(true) - $startTime) / 1_000_000_000;
+ $memoryUsageInMB = (memory_get_usage(true) - $startMemory) / 1024 / 1024;
- /** @Then verify that the total duration of the chained operations is within limits */
- self::assertLessThan(15, $endTime - $startTime);
+ /** @Then verify the value of the first element in the sorted collection */
+ self::assertSame(220_000.0, $firstElement->value);
+
+ /** @And verify that the total duration of the chained operations is within limits */
+ self::assertLessThan(
+ 15.0,
+ $executionTimeInSeconds,
+ sprintf('Execution time %.2fs exceeded 15s limit', $executionTimeInSeconds)
+ );
/** @And verify that memory usage is within acceptable limits */
- $memoryUsageInMB = ($endMemory - $startMemory) / 1024 / 1024;
- self::assertLessThan(0.3, $memoryUsageInMB);
+ self::assertLessThan(
+ 50.0,
+ $memoryUsageInMB,
+ sprintf('Memory usage %.2fMB exceeded 50MB limit', $memoryUsageInMB)
+ );
+ }
+
+ public function testCollectionUsesLessMemoryThanArrayForFirstElementRetrieval(): void
+ {
+ /** @Given a large dataset with 1 million elements */
+ $totalElements = 1_000_000;
+ $targetValue = 1000;
+
+ /** @When finding the first matching element with an array */
+ $this->forceGarbageCollection();
+ $startArrayMemory = memory_get_usage(true);
+
+ /** @And create array and find first element greater than target */
+ $array = range(1, $totalElements);
+ $arrayResult = null;
+ foreach ($array as $value) {
+ if ($value > $targetValue) {
+ $arrayResult = $value;
+ break;
+ }
+ }
+
+ /** @And measure memory usage for the array operations */
+ $arrayMemoryUsageInBytes = memory_get_usage(true) - $startArrayMemory;
+
+ /** @And clean up array memory before Collection test */
+ unset($array);
+ $this->forceGarbageCollection();
+
+ /** @When finding the first matching element with Collection using a generator */
+ $startCollectionMemory = memory_get_usage(true);
+
+ /** @And create collection from generator and find first element greater than target */
+ $generator = (static function () use ($totalElements): Generator {
+ for ($index = 1; $index <= $totalElements; $index++) {
+ yield $index;
+ }
+ })();
+
+ $collectionResult = Collection::createFrom(elements: $generator)
+ ->filter(static fn(int $value): bool => $value > $targetValue)
+ ->first();
+
+ /** @And measure memory usage for the Collection operations */
+ $collectionMemoryUsageInBytes = memory_get_usage(true) - $startCollectionMemory;
+
+ /** @Then assert that both approaches produce the same result */
+ self::assertSame($arrayResult, $collectionResult);
+
+ /** @And assert that Collection uses less memory than array */
+ self::assertLessThan(
+ $arrayMemoryUsageInBytes,
+ $collectionMemoryUsageInBytes,
+ sprintf(
+ 'Collection (%d bytes) should use less memory than array (%d bytes)',
+ $collectionMemoryUsageInBytes,
+ $arrayMemoryUsageInBytes
+ )
+ );
+ }
+
+ private function forceGarbageCollection(): void
+ {
+ gc_enable();
+ gc_collect_cycles();
+ gc_mem_caches();
}
}
diff --git a/tests/CollectionTest.php b/tests/CollectionTest.php
index 8b73971..c53b46a 100644
--- a/tests/CollectionTest.php
+++ b/tests/CollectionTest.php
@@ -29,13 +29,13 @@ public function testChainedOperationsWithObjects(): void
* and use each to accumulate the total discounted value */
$totalDiscounted = 0;
$actual = $collection
- ->filter(predicates: fn(Amount $amount): bool => $amount->value >= 100)
- ->map(transformations: fn(Amount $amount): Amount => new Amount(
+ ->filter(predicates: static fn(Amount $amount): bool => $amount->value >= 100)
+ ->map(transformations: static fn(Amount $amount): Amount => new Amount(
value: $amount->value * 0.9,
currency: $amount->currency
))
- ->removeAll(filter: fn(Amount $amount): bool => $amount->value > 300)
- ->sort(order: Order::ASCENDING_VALUE, predicate: fn(
+ ->removeAll(filter: static fn(Amount $amount): bool => $amount->value > 300)
+ ->sort(order: Order::ASCENDING_VALUE, predicate: static fn(
Amount $first,
Amount $second
): int => $first->value <=> $second->value)
@@ -65,8 +65,8 @@ public function testChainedOperationsWithIntegers(): void
* Then mapping each number to its square,
* And sorting the squared numbers in descending order */
$actual = $collection
- ->filter(predicates: fn(int $value): bool => $value % 2 === 0)
- ->map(transformations: fn(int $value): int => $value ** 2)
+ ->filter(predicates: static fn(int $value): bool => $value % 2 === 0)
+ ->map(transformations: static fn(int $value): int => $value ** 2)
->sort(order: Order::DESCENDING_VALUE);
/** @Then the first element after sorting should be 10,000 (square of 100) */
@@ -76,7 +76,7 @@ public function testChainedOperationsWithIntegers(): void
self::assertSame(4, $actual->last());
/** @When reducing the collection to calculate the sum of all squared even numbers */
- $sum = $actual->reduce(aggregator: fn(int $carry, int $value): int => $carry + $value, initial: 0);
+ $sum = $actual->reduce(aggregator: static fn(int $carry, int $value): int => $carry + $value, initial: 0);
/** @Then the sum of squared even numbers should be correct (171700) */
self::assertSame(171700, $sum);
diff --git a/tests/Models/InvoiceSummaries.php b/tests/Models/InvoiceSummaries.php
index 567f51e..c13ed30 100644
--- a/tests/Models/InvoiceSummaries.php
+++ b/tests/Models/InvoiceSummaries.php
@@ -11,9 +11,9 @@ final class InvoiceSummaries extends Collection
public function sumByCustomer(string $customer): float
{
return $this
- ->filter(predicates: fn(InvoiceSummary $summary): bool => $summary->customer === $customer)
+ ->filter(predicates: static fn(InvoiceSummary $summary): bool => $summary->customer === $customer)
->reduce(
- aggregator: fn(float $carry, InvoiceSummary $summary): float => $carry + $summary->amount,
+ aggregator: static fn(float $carry, InvoiceSummary $summary): float => $carry + $summary->amount,
initial: 0.0
);
}
diff --git a/tests/Operations/Aggregate/CollectionReduceOperationTest.php b/tests/Operations/Aggregate/CollectionReduceOperationTest.php
index f50139b..d0e44d0 100644
--- a/tests/Operations/Aggregate/CollectionReduceOperationTest.php
+++ b/tests/Operations/Aggregate/CollectionReduceOperationTest.php
@@ -33,7 +33,7 @@ public function testReduceSumOfNumbers(): void
/** @When reducing the collection to a sum */
$actual = $numbers->reduce(
- aggregator: fn(int $carry, int $number): int => $carry + $number,
+ aggregator: static fn(int $carry, int $number): int => $carry + $number,
initial: 0
);
@@ -48,7 +48,7 @@ public function testReduceProductOfNumbers(): void
/** @When reducing the collection to a product */
$actual = $numbers->reduce(
- aggregator: fn(int $carry, int $number): int => $carry * $number,
+ aggregator: static fn(int $carry, int $number): int => $carry * $number,
initial: 1
);
@@ -67,8 +67,8 @@ public function testReduceWhenNoMatchFound(): void
/** @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,
+ ->filter(predicates: static fn(InvoiceSummary $summary): bool => $summary->customer === 'Customer C')
+ ->reduce(aggregator: static fn(float $carry, InvoiceSummary $summary): float => $carry + $summary->amount,
initial: 0.0);
/** @Then the total amount for 'Customer C' should be zero */
@@ -82,7 +82,7 @@ public function testReduceWithMixedDataTypes(): void
/** @When reducing the collection by concatenating values as strings */
$actual = $mixedData->reduce(
- aggregator: fn(string $carry, mixed $value): string => $carry . $value,
+ aggregator: static fn(string $carry, mixed $value): string => $carry . $value,
initial: ''
);
@@ -97,7 +97,7 @@ public function testReduceSumForEmptyCollection(): void
/** @When reducing an empty collection */
$actual = $summaries->reduce(
- aggregator: fn(float $carry, InvoiceSummary $summary): float => $carry + $summary->amount,
+ aggregator: static fn(float $carry, InvoiceSummary $summary): float => $carry + $summary->amount,
initial: 0.0
);
@@ -116,8 +116,8 @@ public function testReduceWithDifferentInitialValue(): void
/** @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,
+ ->filter(predicates: static fn(InvoiceSummary $summary): bool => $summary->customer === 'Customer A')
+ ->reduce(aggregator: static fn(float $carry, InvoiceSummary $summary): float => $carry + $summary->amount,
initial: 50.0);
/** @Then the total amount for 'Customer A' should be 300.5 */
diff --git a/tests/Operations/Retrieve/CollectionFindOperationTest.php b/tests/Operations/Retrieve/CollectionFindOperationTest.php
index cc5f9bc..1627eba 100644
--- a/tests/Operations/Retrieve/CollectionFindOperationTest.php
+++ b/tests/Operations/Retrieve/CollectionFindOperationTest.php
@@ -16,7 +16,7 @@ public function testFindByInEmptyCollection(): void
$collection = Collection::createFromEmpty();
/** @When attempting to find any element */
- $actual = $collection->findBy(predicates: fn(mixed $element): bool => true);
+ $actual = $collection->findBy(predicates: static fn(mixed $element): bool => true);
/** @Then the result should be null */
self::assertNull($actual);
@@ -32,7 +32,8 @@ public function testFindByReturnsNullWhenNoMatch(): void
]);
/** @When attempting to find an element that doesn't match any condition */
- $actual = $collection->findBy(predicates: fn(CryptoCurrency $element): bool => $element->symbol === 'XRP');
+ $actual = $collection->findBy(predicates: static fn(CryptoCurrency $element): bool => $element->symbol === 'XRP'
+ );
/** @Then the result should be null */
self::assertNull($actual);
@@ -50,8 +51,8 @@ public function testFindByWithMultiplePredicates(): void
/** @When attempting to find the first element matching multiple predicates */
$actual = $collection->findBy(
- fn(CryptoCurrency $element): bool => $element->symbol === 'BNB',
- fn(CryptoCurrency $element): bool => $element->price < 2000.0
+ static fn(CryptoCurrency $element): bool => $element->symbol === 'BNB',
+ static fn(CryptoCurrency $element): bool => $element->price < 2000.0
);
/** @Then the result should be the expected element */
@@ -69,7 +70,8 @@ public function testFindByReturnsFirstMatchingElement(): void
$collection = Collection::createFrom(elements: $elements);
/** @When attempting to find the first matching element */
- $actual = $collection->findBy(predicates: fn(CryptoCurrency $element): bool => $element->symbol === 'ETH');
+ $actual = $collection->findBy(predicates: static fn(CryptoCurrency $element): bool => $element->symbol === 'ETH'
+ );
/** @Then the result should be the expected element */
self::assertSame($elements[1], $actual);
@@ -86,8 +88,8 @@ public function testFindByWithMultiplePredicatesReturnsNullWhenNoMatch(): void
/** @When attempting to find an element matching multiple predicates that do not match */
$actual = $collection->findBy(
- fn(CryptoCurrency $element): bool => $element->symbol === 'XRP',
- fn(CryptoCurrency $element): bool => $element->price < 1000.0
+ static fn(CryptoCurrency $element): bool => $element->symbol === 'XRP',
+ static fn(CryptoCurrency $element): bool => $element->price < 1000.0
);
/** @Then the result should be null */
diff --git a/tests/Operations/Write/CollectionRemoveOperationTest.php b/tests/Operations/Write/CollectionRemoveOperationTest.php
index cf7d3a6..fb6803f 100644
--- a/tests/Operations/Write/CollectionRemoveOperationTest.php
+++ b/tests/Operations/Write/CollectionRemoveOperationTest.php
@@ -60,7 +60,7 @@ public function testRemoveSpecificElementUsingFilter(): void
$collection = Collection::createFrom(elements: [$bitcoin, $ethereum]);
/** @When removing the Bitcoin (BTC) element using a filter */
- $actual = $collection->removeAll(filter: fn(CryptoCurrency $item) => $item === $bitcoin);
+ $actual = $collection->removeAll(filter: static fn(CryptoCurrency $item) => $item === $bitcoin);
/** @Then the collection should no longer contain the removed element */
self::assertSame([$ethereum->toArray()], $actual->toArray());