From 4737a6289af3149e10adb2c97984f5b91dd439d6 Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Tue, 13 Jan 2026 22:26:07 -0300 Subject: [PATCH] refactor: Refactor for PHP 8.5, improve static analysis, and enhance performance tests, refactor all closures to use static where possible for performance and clarity. --- .gitattributes | 1 - .github/workflows/ci.yml | 68 +++-- Makefile | 60 +++- README.md | 14 +- composer.json | 21 +- phpmd.xml | 59 ---- phpstan.neon.dist | 1 + src/Internal/Operations/Filter/Filter.php | 26 +- src/Internal/Operations/Retrieve/Find.php | 6 +- src/Internal/Operations/Retrieve/First.php | 4 + src/Internal/Operations/Retrieve/Last.php | 4 + src/Internal/Operations/Retrieve/Slice.php | 32 +- tests/CollectionPerformanceTest.php | 283 ++++++++++++++---- tests/CollectionTest.php | 14 +- tests/Models/InvoiceSummaries.php | 4 +- .../CollectionReduceOperationTest.php | 16 +- .../Retrieve/CollectionFindOperationTest.php | 16 +- .../Write/CollectionRemoveOperationTest.php | 2 +- 18 files changed, 419 insertions(+), 212 deletions(-) delete mode 100644 phpmd.xml 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());