From ed9f3641f581aefdc06606e0353a2dd06b587864 Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Wed, 15 Apr 2026 15:11:25 -0300 Subject: [PATCH 01/15] feat: Add lazy collection creation from closure and enhance collection methods. --- .claude/CLAUDE.md | 30 +++ .claude/rules/documentation.md | 37 ++++ .claude/rules/github-workflows.md | 78 ++++++++ .claude/rules/php-code-style.md | 121 ++++++++++++ .claude/rules/php-domain.md | 96 +++++++++ .claude/rules/php-testing.md | 120 ++++++++++++ .gitattributes | 43 +++-- src/Collectible.php | 16 +- src/Collection.php | 17 +- src/Internal/EagerPipeline.php | 53 +++-- src/Internal/LazyPipeline.php | 21 +- .../Operations/Transforming/Rearrange.php | 2 +- tests/CollectionTest.php | 58 ++++++ tests/EagerCollectionTest.php | 39 +++- tests/LazyCollectionTest.php | 182 +++++++++++++++++- 15 files changed, 839 insertions(+), 74 deletions(-) create mode 100644 .claude/CLAUDE.md create mode 100644 .claude/rules/documentation.md create mode 100644 .claude/rules/github-workflows.md create mode 100644 .claude/rules/php-code-style.md create mode 100644 .claude/rules/php-domain.md create mode 100644 .claude/rules/php-testing.md diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 0000000..3e73900 --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1,30 @@ +# Project + +PHP microservices platform. Hexagonal architecture (ports & adapters), DDD, CQRS. + +## Rules + +All coding standards, architecture, naming, testing, documentation, and OpenAPI conventions +are defined in `rules/`. Read the applicable rule files before generating any code or documentation. + +## Commands + +- `make test` — run tests with coverage. +- `make mutation-test` — run mutation testing (Infection). +- `make review` — run lint. +- `make help` — list all available commands. + +## Post-change validation + +After any code change, run `make review`, `make test`, and `make mutation-test`. +If any fails, iterate on the fix while respecting all project rules until all pass. +Never deliver code that breaks lint, tests, or leaves surviving mutants. + +## File formatting + +Every file produced or modified must: + +- Use **LF** line endings. Never CRLF. +- Have no trailing whitespace on any line. +- End with a single trailing newline. +- Have no consecutive blank lines (max one blank line between blocks). diff --git a/.claude/rules/documentation.md b/.claude/rules/documentation.md new file mode 100644 index 0000000..64587c9 --- /dev/null +++ b/.claude/rules/documentation.md @@ -0,0 +1,37 @@ +--- +description: Standards for README files and all project documentation in PHP libraries. +paths: + - "**/*.md" +--- + +# Documentation + +## README + +1. Include an anchor-linked table of contents. +2. Start with a concise one-line description of what the library does. +3. Include a **badges** section (license, build status, coverage, latest version, PHP version). +4. Provide an **Overview** section explaining the problem the library solves and its design philosophy. +5. **Installation** section: Composer command (`composer require vendor/package`). +6. **How to use** section: complete, runnable code examples covering the primary use cases. Each example + includes a brief heading describing what it demonstrates. +7. If the library exposes multiple entry points, strategies, or container types, document each with its own + subsection and example. +8. **FAQ** section: include entries for common pitfalls, non-obvious behaviors, or design decisions that users + frequently ask about. Each entry is a numbered question as heading (e.g., `### 01. Why does X happen?`) + followed by a concise explanation. Only include entries that address real confusion points. +9. **License** and **Contributing** sections at the end. +10. Write strictly in American English. See `rules/code-style.md` American English section for spelling conventions. + +## Structured data + +1. When documenting constructors, factory methods, or configuration options with more than 3 parameters, + use tables with columns: Parameter, Type, Required, Description. +2. Prefer tables to prose for any structured information. + +## Style + +1. Keep language concise and scannable. +2. Never include placeholder content (`TODO`, `TBD`). +3. Code examples must be syntactically correct and self-contained. +4. Do not document `Internal/` classes or private API. Only document what consumers interact with. diff --git a/.claude/rules/github-workflows.md b/.claude/rules/github-workflows.md new file mode 100644 index 0000000..a369ba4 --- /dev/null +++ b/.claude/rules/github-workflows.md @@ -0,0 +1,78 @@ +--- +description: Naming, ordering, inputs, security, and structural rules for all GitHub Actions workflow files. +paths: + - ".github/workflows/**/*.yml" + - ".github/workflows/**/*.yaml" +--- + +# Workflows + +Structural and stylistic rules for GitHub Actions workflow files. Refer to `shell-scripts.md` for Bash conventions used +inside `run:` steps, and to `terraforms.md` for Terraform conventions used in `terraform/`. + +## Pre-output checklist + +Verify every item before producing any workflow YAML. If any item fails, revise before outputting. + +1. File name follows the convention: `ci-.yml` for reusable CI, `cd-.yml` for dispatch CD. +2. `name` field follows the pattern `CI — ` or `CD — `, using sentence case after the dash + (e.g., `CD — Run migration`, not `CD — Run Migration`). +3. Reusable workflows use `workflow_call` trigger. CD workflows use `workflow_dispatch` trigger. +4. Each workflow has a single responsibility. CI tests code. CD deploys it. Never combine both. +5. Every input has a `description` field. Descriptions use American English and end with a period. +6. Input names use `kebab-case`: `service-name`, `dry-run`, `skip-build`. +7. Inputs are ordered: required first, then optional. Each group by **name length ascending**. +8. Choice input options are in **alphabetical order**. +9. `env`, `outputs`, and `with` entries are ordered by **key length ascending**. +10. `permissions` keys are ordered by **key length ascending** (`contents` before `id-token`). +11. Top-level workflow keys follow canonical order: `name`, `on`, `concurrency`, `permissions`, `env`, `jobs`. +12. Job-level properties follow canonical order: `if`, `name`, `needs`, `uses`, `with`, `runs-on`, + `environment`, `timeout-minutes`, `strategy`, `outputs`, `permissions`, `env`, `steps`. +13. All other YAML property names within a block are ordered by **name length ascending**. +14. Jobs follow execution order: `load-config` → `lint` → `test` → `build` → `deploy`. +15. Step names start with a verb and use sentence case: `Setup PHP`, `Run lint`, `Resolve image tag`. +16. Runtime versions are resolved from the service repo's native dependency file (`composer.json`, `go.mod`, + `package.json`). No version is hardcoded in any workflow. +17. Service-specific overrides live in a pipeline config file (e.g., `.pipeline.yml`) in the service repo, + not in the workflows repository. +18. The `load-config` job reads the pipeline config file at runtime with safe fallback to defaults when absent. +19. Top-level `permissions` defaults to read-only (`contents: read`). Jobs escalate only the permissions they + need. +20. AWS authentication uses OIDC federation exclusively. Static access keys are forbidden. +21. Secrets are passed via `secrets: inherit` from callers. No secret is hardcoded. +22. Sensitive values fetched from SSM are masked with `::add-mask::` before assignment. +23. Third-party actions are pinned to the latest available full commit SHA with a version comment: + `uses: aws-actions/configure-aws-credentials@ # v4.0.2`. Always verify the latest + version before generating a workflow. +24. First-party actions (`actions/*`) are pinned to the latest major version tag available: + `actions/checkout@v4`. Always check for the most recent major version before generating a workflow. +25. Production deployments require GitHub Environments protection rules (manual approval). +26. Every job sets `timeout-minutes` to prevent indefinite hangs. CI jobs: 10–15 minutes. CD jobs: 20–30 + minutes. Adjust only with justification in a comment. +27. CI workflows set `concurrency` with `group` scoped to the PR and `cancel-in-progress: true` to avoid + redundant runs. +28. CD workflows set `concurrency` with `group` scoped to the environment and `cancel-in-progress: false` to + prevent interrupted deployments. +29. CD workflows use `if: ${{ !cancelled() }}` to allow to deploy after optional build steps. +30. Inline logic longer than 3 lines is extracted to a script in `scripts/ci/` or `scripts/cd/`. + +## Style + +- All text (workflow names, step names, input descriptions, comments) uses American English with correct + spelling and punctuation. Sentences and descriptions end with a period. + +## Callers + +- Callers trigger on `pull_request` targeting `main` only. No `push` trigger. +- Callers in service repos are static (~10 lines) and pass only `service-name` or `app-name`. +- Callers reference workflows with `@main` during development. Pin to a tag or SHA for production. + +## Image tagging + +- CD deploy builds: `-sha-` + `latest`. + +## Migrations + +- Migrations run **before** service deployment (schema first, code second). +- `cd-migrate.yml` supports `dry-run` mode (`flyway validate`) for pre-flight checks. +- Database credentials are fetched from SSM at runtime, never stored in workflow files. diff --git a/.claude/rules/php-code-style.md b/.claude/rules/php-code-style.md new file mode 100644 index 0000000..59323ba --- /dev/null +++ b/.claude/rules/php-code-style.md @@ -0,0 +1,121 @@ +--- +description: Pre-output checklist, naming, typing, comparisons, and PHPDoc rules for all PHP files in libraries. +paths: + - "src/**/*.php" + - "tests/**/*.php" +--- + +# Code style + +Semantic code rules for all PHP files. Formatting rules (PSR-1, PSR-4, PSR-12, line length) are enforced by `phpcs.xml` +and are not repeated here. Refer to `rules/domain.md` for domain modeling rules. + +## Pre-output checklist + +Verify every item before producing any PHP code. If any item fails, revise before outputting. + +1. `declare(strict_types=1)` is present. +2. All classes are `final readonly` by default. Use `class` (without `final` or `readonly`) only when the class is + designed as an extension point for consumers (e.g., `Collection`, `ValueObject`). Use `final class` without + `readonly` only when the parent class is not readonly (e.g., extending a third-party abstract class). +3. All parameters, return types, and properties have explicit types. +4. Constructor property promotion is used. +5. Named arguments are used at call sites for own code, tests, and third-party library methods (e.g., tiny-blocks). + Never use named arguments on native PHP functions (`array_map`, `in_array`, `preg_match`, `is_null`, + `iterator_to_array`, `sprintf`, `implode`, etc.) or PHPUnit assertions (`assertEquals`, `assertSame`, + `assertTrue`, `expectException`, etc.). +6. No `else` or `else if` exists anywhere. Use early returns, polymorphism, or map dispatch instead. +7. No abbreviations appear in identifiers. Use `$index` instead of `$i`, `$account` instead of `$acc`. +8. No generic identifiers exist. Use domain-specific names instead: + `$data` → `$payload`, `$value` → `$totalAmount`, `$item` → `$element`, + `$info` → `$currencyDetails`, `$result` → `$conversionOutcome`. +9. No raw arrays exist where a typed collection or value object is available. Use `tiny-blocks/collection` + (`Collection`, `Collectible`) instead of raw `array` for any list of domain objects. Raw arrays are acceptable + only for primitive configuration data, variadic pass-through, or interop at system boundaries. +10. No private methods exist except private constructors for factory patterns. Inline trivial logic at the call site + or extract it to a collaborator or value object. +11. Members are ordered: constants first, then constructor, then static methods, then instance methods. Within each + group, order by body size ascending (number of lines between `{` and `}`). Constants and enum cases, which have + no body, are ordered by name length ascending. +12. Constructor parameters are ordered by parameter name length ascending (count the name only, without `$` or type), + except when parameters have an implicit semantic order (e.g., `$start/$end`, `$from/$to`, `$startAt/$endAt`), + which takes precedence. The same rule applies to named arguments at call sites. + Example: `$id` (2) → `$value` (5) → `$status` (6) → `$precision` (9). +13. No O(N²) or worse complexity exists. +14. No logic is duplicated across two or more places (DRY). +15. No abstraction exists without real duplication or isolation need (KISS). +16. All identifiers, comments, and documentation are written in American English. +17. No justification comments exist (`// NOTE:`, `// REASON:`, etc.). Code speaks for itself. +18. `// TODO: ` is used when implementation is unknown, uncertain, or intentionally deferred. + Never leave silent gaps. +19. All class references use `use` imports at the top of the file. Fully qualified names inline are prohibited. +20. No dead or unused code exists. Remove unreferenced classes, methods, constants, and imports. +21. Never create public methods, constants, or classes in `src/` solely to serve tests. If production code does not + need it, it does not exist. +22. Always use the most current and clean syntax available in the target PHP version. Prefer match to switch, + first-class callables over `Closure::fromCallable()`, readonly promotion over manual assignment, enum methods + over external switch/if chains, named arguments over positional ambiguity (except where excluded by rule 5), + and `Collection::map` over foreach accumulation. +23. No vertical alignment of types in parameter lists or property declarations. Use a single space between + type and variable name. Never pad with extra spaces to align columns: + `public OrderId $id` — not `public OrderId $id`. +24. Opening brace `{` goes on the same line as the closing parenthesis `)` for constructors, methods, and + closures: `): ReturnType {` — not `): ReturnType\n {`. Parameters with default values go last. + +## Casing conventions + +- Internal code (variables, methods, classes): **`camelCase`**. +- Constants and enum-backed values when representing codes: **`SCREAMING_SNAKE_CASE`**. + +## Naming + +- Names describe **what** in domain terms, not **how** technically: `$monthlyRevenue` instead of `$calculatedValue`. +- Generic technical verbs (`process`, `handle`, `execute`, `mark`, `enforce`, `manage`, `ensure`, `validate`, + `check`, `verify`, `assert`, `transform`, `parse`, `compute`, `sanitize`, `normalize`) **should be avoided**. + Prefer names that describe the domain operation. +- Booleans use predicate form: `isActive`, `hasPermission`, `wasProcessed`. +- Collections are always plural: `$orders`, `$lines`. +- Methods returning bool use prefixes: `is`, `has`, `can`, `was`, `should`. + +## Comparisons + +1. Null checks: use `is_null($variable)`, never `$variable === null`. +2. Empty string checks on typed `string` parameters: use `$variable === ''`. Avoid `empty()` on typed strings + because `empty('0')` returns `true`. +3. Mixed or untyped checks (value may be `null`, empty string, `0`, or `false`): use `empty($variable)`. + +## American English + +All identifiers, enum values, comments, and error codes use American English spelling: +`canceled` (not `cancelled`), `organization` (not `organisation`), `initialize` (not `initialise`), +`behavior` (not `behaviour`), `modeling` (not `modelling`), `labeled` (not `labelled`), +`fulfill` (not `fulfil`), `color` (not `colour`). + +## PHPDoc + +- PHPDoc is restricted to interfaces only, documenting obligations and `@throws`. +- Never add PHPDoc to concrete classes. + +## Collection usage + +When a property or parameter is `Collectible`, use its fluent API. Never break out to raw array functions. + +**Prohibited — `array_map` + `iterator_to_array` on a Collectible:** + +```php +$names = array_map( + static fn(Element $element): string => $element->name(), + iterator_to_array($collection) +); +``` + +**Correct — fluent chain with `map()` + `toArray()`:** + +```php +$names = $collection + ->map(transformations: static fn(Element $element): string => $element->name()) + ->toArray(keyPreservation: KeyPreservation::DISCARD); +``` + +The same applies to `filter()`, `reduce()`, `each()`, and all other `Collectible` operations. Chain them +fluently. Never materialize with `iterator_to_array` to then pass into a raw `array_*` function. diff --git a/.claude/rules/php-domain.md b/.claude/rules/php-domain.md new file mode 100644 index 0000000..f3b0eea --- /dev/null +++ b/.claude/rules/php-domain.md @@ -0,0 +1,96 @@ +--- +description: Domain modeling rules for PHP libraries — folder structure, naming, value objects, exceptions, enums, and SOLID. +paths: + - "src/**/*.php" +--- + +# Domain modeling + +Libraries are self-contained packages. The core has no dependency on frameworks, databases, or I/O. +Refer to `rules/code-style.md` for the pre-output checklist applied to all PHP code. + +## Folder structure + +``` +src/ +├── .php # Primary contract for consumers +├── .php # Main implementation or extension point +├── .php # Public enum +├── Contracts/ # Interfaces for data returned to consumers +├── Internal/ # Implementation details (not part of public API) +│ ├── .php +│ └── Exceptions/ # Internal exception classes +├── / # Feature-specific subdirectory when needed +└── Exceptions/ # Public exception classes (when part of the API) +``` + +**Public API boundary:** Only interfaces, extension points, enums, and thin orchestration classes live at the +`src/` root. These classes define the contract consumers interact with and delegate all real work to collaborators +inside `src/Internal/`. If a class contains substantial logic (algorithms, state machines, I/O), it belongs in +`Internal/`, not at the root. + +The `Internal/` namespace signals classes that are implementation details. Consumers must not depend on them. +Never use `Entities/`, `ValueObjects/`, `Enums/`, or `Domain/` as folder names. + +## Nomenclature + +1. Every class, property, method, and exception name reflects the **domain concept** the library represents. + A math library uses `Precision`, `RoundingMode`; a money library uses `Currency`, `Amount`; a collection + library uses `Collectible`, `Order`. +2. Never use generic technical names: `Manager`, `Helper`, `Processor`, `Data`, `Info`, `Utils`, + `Item`, `Record`, `Entity`, `Exception`, `Ensure`, `Validate`, `Check`, `Verify`, + `Assert`, `Transform`, `Parse`, `Compute`, `Sanitize`, or `Normalize` as class suffixes or prefixes. +3. Name classes after what they represent: `Money`, `Color`, `Pipeline` — not after what they do technically. +4. Name methods after the operation in domain terms: `add()`, `convertTo()`, `splitAt()` — not `process()`, + `handle()`, `execute()`, `manage()`, `ensure()`, `validate()`, `check()`, `verify()`, `assert()`, + `transform()`, `parse()`, `compute()`, `sanitize()`, or `normalize()`. + +## Value objects + +1. Are immutable: no setters, no mutation after construction. Operations return new instances. +2. Compare by value, not by reference. +3. Validate invariants in the constructor and throw on invalid input. +4. Have no identity field. +5. Use static factory methods (e.g., `from`, `of`, `zero`) with a private constructor when multiple creation + paths exist. + +## Exceptions + +1. Extend native PHP exceptions (`DomainException`, `InvalidArgumentException`, `OverflowException`, etc.). +2. Are pure: no formatted `code`/`message` for HTTP responses. +3. Signal invariant violations only. +4. Name after the invariant violated, never after the technical type: + `PrecisionOutOfRange` — not `InvalidPrecisionException`. + `CurrencyMismatch` — not `BadCurrencyException`. + `ContainerWaitTimeout` — not `TimeoutException`. +5. Create the exception class directly with the invariant name and the appropriate native parent. The exception + is dedicated by definition when its name describes the specific invariant it guards. + +## Enums + +1. Are PHP backed enums. +2. Include domain-meaningful methods when needed (e.g., `Order::ASCENDING_KEY`). + +## Extension points + +1. When a class is designed to be extended by consumers (e.g., `Collection`, `ValueObject`), it uses `class` + instead of `final readonly class`. All other classes use `final readonly class`. +2. Extension point classes use a private constructor with static factory methods (`createFrom`, `createFromEmpty`) + as the only creation path. +3. Internal state is injected via the constructor and stored in a `private readonly` property. + +## Principles + +- **Immutability**: all models and value objects adopt immutability. Operations return new instances. +- **Zero dependencies**: the library's core has no dependency on frameworks, databases, or I/O. +- **Small surface area**: expose only what consumers need. Hide implementation in `Internal/`. + +## SOLID reference + +| Principle | Failure signal | +|---------------------------|---------------------------------------------| +| S — Single responsibility | Class does two unrelated things | +| O — Open/closed | Adding a feature requires editing internals | +| L — Liskov substitution | Subclass throws on parent method | +| I — Interface segregation | Interface has unused methods | +| D — Dependency inversion | Constructor uses `new ConcreteClass()` | diff --git a/.claude/rules/php-testing.md b/.claude/rules/php-testing.md new file mode 100644 index 0000000..7bd9e68 --- /dev/null +++ b/.claude/rules/php-testing.md @@ -0,0 +1,120 @@ +--- +description: BDD Given/When/Then structure, PHPUnit conventions, test organization, and fixture rules for PHP libraries. +paths: + - "tests/**/*.php" +--- + +# Testing conventions + +Framework: **PHPUnit**. Refer to `rules/code-style.md` for the code style checklist, which also applies to test files. + +## Structure: Given/When/Then (BDD) + +Every test uses `/** @Given */`, `/** @And */`, `/** @When */`, `/** @Then */` doc comments without exception. + +### Happy path example + +```php +public function testAddMoneyWhenSameCurrencyThenAmountsAreSummed(): void +{ + /** @Given two money instances in the same currency */ + $ten = Money::of(amount: 1000, currency: Currency::BRL); + $five = Money::of(amount: 500, currency: Currency::BRL); + + /** @When adding them together */ + $total = $ten->add(other: $five); + + /** @Then the result contains the sum of both amounts */ + self::assertEquals(expected: 1500, actual: $total->amount()); +} +``` + +### Exception example + +When testing that an exception is thrown, place `@Then` (expectException) **before** `@When`. PHPUnit requires this +ordering. + +```php +public function testAddMoneyWhenDifferentCurrenciesThenCurrencyMismatch(): void +{ + /** @Given two money instances in different currencies */ + $brl = Money::of(amount: 1000, currency: Currency::BRL); + $usd = Money::of(amount: 500, currency: Currency::USD); + + /** @Then an exception indicating currency mismatch should be thrown */ + $this->expectException(CurrencyMismatch::class); + + /** @When trying to add money with different currencies */ + $brl->add(other: $usd); +} +``` + +Use `@And` for complementary preconditions or actions within the same scenario, avoiding consecutive `@Given` or +`@When` tags. + +## Rules + +1. Include exactly one `@When` per test. Two actions require two tests. +2. Test only the public API. Never assert on private state or `Internal/` classes directly. +3. Never mock internal collaborators. Use real objects. Use test doubles only at system boundaries (filesystem, + clock, network) when the library interacts with external resources. +4. Name tests to describe behavior, not method names. +5. Never include conditional logic inside tests. +6. Include one logical concept per `@Then` block. +7. Maintain strict independence between tests. No inherited state. +8. For exception tests, place `@Then` (expectException) before `@When`. +9. Use domain-specific model classes in `tests/Models/` for test fixtures that represent domain concepts + (e.g., `Amount`, `Invoice`, `Order`). +10. Use mock classes in `tests/Mocks/` (or `tests/Unit/Mocks/`) for test doubles of system boundaries + (e.g., `ClientMock`, `ExecutionCompletedMock`). +11. Exercise invariants and edge cases through the library's public entry point. Create a dedicated test class + for an internal model only when the condition cannot be reached through the public API. +12. Never use `/** @test */` annotation. Test methods are discovered by the `test` prefix in the method name. +13. Never use named arguments on PHPUnit assertions (`assertEquals`, `assertSame`, `assertTrue`, + `expectException`, etc.). Pass arguments positionally. + +## Test setup and fixtures + +1. **One annotation = one statement.** Each `@Given` or `@And` block contains exactly one annotation line + followed by one expression or assignment. Never place multiple variable declarations or object + constructions under a single annotation. +2. **No intermediate variables used only once.** If a value is consumed in a single place, inline it at the + call site. Chain method calls when the intermediate state is not referenced elsewhere + (e.g., `Money::of(...)->add(...)` instead of `$money = Money::of(...); $money->add(...);`). +3. **No private or helper methods in test classes.** The only non-test methods allowed are data providers. + If setup logic is complex enough to extract, it belongs in a dedicated fixture class, not in a + private method on the test class. +4. **Domain terms in variables and annotations.** Never use technical testing jargon (`$spy`, `$mock`, + `$stub`, `$fake`, `$dummy`) as variable or property names. Use the domain concept the object + represents: `$collection`, `$amount`, `$currency`, `$sortedElements`. Class names like + `ClientMock` or `GatewaySpy` are acceptable — the variable holding the instance is what matters. +5. **Annotations use domain language.** Write `/** @Given a collection of amounts */`, not + `/** @Given a mocked collection in test state */`. The annotation describes the domain + scenario, not the technical setup. + +## Test organization + +``` +tests/ +├── Models/ # Domain-specific fixtures reused across tests +├── Mocks/ # Test doubles for system boundaries +├── Unit/ # Unit tests for public API +│ └── Mocks/ # Alternative location for test doubles +├── Integration/ # Tests requiring real external resources (Docker, filesystem) +└── bootstrap.php # Test bootstrap when needed +``` + +- `tests/` or `tests/Unit/`: pure unit tests exercising the library's public API. +- `tests/Integration/`: tests requiring real external resources (e.g., Docker containers, databases). + Only present when the library interacts with infrastructure. +- `tests/Models/`: domain-specific fixture classes reused across test files. +- `tests/Mocks/` or `tests/Unit/Mocks/`: test doubles for system boundaries. + +## Coverage and mutation testing + +1. Line and branch coverage must be **100%**. No annotations (`@codeCoverageIgnore`), attributes, or configuration + that exclude code from coverage are allowed. +2. All mutations reported by Infection must be **killed**. Never ignore or suppress mutants via `infection.json.dist` + or any other mechanism. +3. If a line or mutation cannot be covered or killed, it signals a design problem in the production code. Refactor + the code to make it testable, do not work around the tool. diff --git a/.gitattributes b/.gitattributes index 8c85471..28337dc 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,13 +1,30 @@ -/tests export-ignore -/vendor export-ignore - -/LICENSE export-ignore -/Makefile export-ignore -/README.md export-ignore -/phpunit.xml export-ignore -/phpstan.neon.dist export-ignore -/infection.json.dist export-ignore - -/.github export-ignore -/.gitignore export-ignore -/.gitattributes export-ignore +# Auto detect text files and perform LF normalization +* text=auto eol=lf + +# ─── Diff drivers ──────────────────────────────────────────── +*.php diff=php +*.md diff=markdown + +# ─── Force LF ──────────────────────────────────────────────── +*.sh text eol=lf +Makefile text eol=lf + +# ─── Generated (skip diff and GitHub stats) ────────────────── +composer.lock -diff linguist-generated + +# ─── Export ignore (excluded from dist archive) ────────────── +/tests export-ignore +/vendor export-ignore +/rules export-ignore + +/.github export-ignore +/.gitignore export-ignore +/.gitattributes export-ignore + +/CLAUDE.md export-ignore +/LICENSE export-ignore +/Makefile export-ignore +/README.md export-ignore +/phpunit.xml export-ignore +/phpstan.neon.dist export-ignore +/infection.json.dist export-ignore \ No newline at end of file diff --git a/src/Collectible.php b/src/Collectible.php index 993800b..1b09305 100644 --- a/src/Collectible.php +++ b/src/Collectible.php @@ -57,6 +57,17 @@ public static function createLazyFrom(iterable $elements): static; */ public static function createLazyFromEmpty(): static; + /** + * Creates a collection using lazy evaluation from a closure that produces an iterable. + * + * The closure is invoked each time the collection is iterated, enabling + * safe re-iteration over generators or other single-use iterables. + * + * @param Closure $factory A closure returning an iterable of elements. + * @return static A new collection backed by the given factory. + */ + public static function createLazyFromClosure(Closure $factory): static; + /** * Returns a new collection with the specified elements appended. * @@ -102,10 +113,11 @@ public function findBy(Closure ...$predicates): mixed; /** * Executes side effect actions on every element without modifying the collection. * + * This is a terminal operation. The collection is not returned. + * * @param Closure ...$actions Actions to perform on each element. - * @return static The same instance, enabling further chaining. */ - public function each(Closure ...$actions): static; + public function each(Closure ...$actions): void; /** * Compares this collection with another for element-wise equality. diff --git a/src/Collection.php b/src/Collection.php index 0533484..8f7a2f3 100644 --- a/src/Collection.php +++ b/src/Collection.php @@ -30,14 +30,6 @@ use TinyBlocks\Mapper\IterableMapper; use Traversable; -/** - * Extensible, type-safe collection with a fluent API. - * - * Designed as the primary extension point — domain collections should - * extend this class to inherit all collection behavior: - * - * final class Orders extends Collection { } - */ class Collection implements Collectible, IterableMapper { use IterableMappability; @@ -66,6 +58,11 @@ public static function createLazyFromEmpty(): static return static::createLazyFrom(elements: []); } + public static function createLazyFromClosure(Closure $factory): static + { + return new static(pipeline: LazyPipeline::fromClosure(factory: $factory)); + } + public function getIterator(): Traversable { yield from $this->pipeline->process(); @@ -96,11 +93,9 @@ public function findBy(Closure ...$predicates): mixed return Find::firstMatch(elements: $this, predicates: $predicates); } - public function each(Closure ...$actions): static + public function each(Closure ...$actions): void { Each::execute(elements: $this, actions: $actions); - - return $this; } public function equals(Collectible $other): bool diff --git a/src/Internal/EagerPipeline.php b/src/Internal/EagerPipeline.php index 7dff0bc..cd94429 100644 --- a/src/Internal/EagerPipeline.php +++ b/src/Internal/EagerPipeline.php @@ -7,47 +7,64 @@ use Generator; use TinyBlocks\Collection\Internal\Operations\Operation; -/** - * Array-backed pipeline with immediate evaluation. - * - * Each operation materializes results into an array immediately, - * enabling constant-time access, count, and repeated iteration. - * Ideal for small to medium datasets and random access scenarios. - */ -final readonly class EagerPipeline implements Pipeline +final class EagerPipeline implements Pipeline { - private function __construct(private array $elements) + private ?array $materialized = null; + + /** @var Operation[] */ + private readonly array $stages; + + private function __construct(private readonly iterable $source, array $stages = []) { + $this->stages = $stages; } public static function from(iterable $source): EagerPipeline { - $elements = is_array($source) ? $source : iterator_to_array(iterator: $source); - - return new EagerPipeline(elements: $elements); + return new EagerPipeline(source: $source); } public function pipe(Operation $operation): Pipeline { - $elements = iterator_to_array(iterator: $operation->apply(elements: $this->elements)); + $stages = $this->stages; + $stages[] = $operation; - return new EagerPipeline(elements: $elements); + return new EagerPipeline(source: $this->source, stages: $stages); } public function count(): int { - return count($this->elements); + return count($this->elements()); } public function getBy(int $index, mixed $defaultValueIfNotFound = null): mixed { - return array_key_exists($index, $this->elements) - ? $this->elements[$index] + $elements = $this->elements(); + + return array_key_exists($index, $elements) + ? $elements[$index] : $defaultValueIfNotFound; } public function process(): Generator { - yield from $this->elements; + yield from $this->elements(); + } + + private function elements(): array + { + if (!is_null($this->materialized)) { + return $this->materialized; + } + + $elements = $this->source; + + foreach ($this->stages as $stage) { + $elements = $stage->apply(elements: $elements); + } + + $this->materialized = is_array($elements) ? $elements : iterator_to_array($elements); + + return $this->materialized; } } diff --git a/src/Internal/LazyPipeline.php b/src/Internal/LazyPipeline.php index a8cd6dc..0724a87 100644 --- a/src/Internal/LazyPipeline.php +++ b/src/Internal/LazyPipeline.php @@ -4,24 +4,16 @@ namespace TinyBlocks\Collection\Internal; +use Closure; use Generator; use TinyBlocks\Collection\Internal\Operations\Operation; -/** - * Generator-based pipeline with deferred evaluation. - * - * Operations are accumulated as stages and executed only when - * the pipeline is consumed. Ideal for large or unbounded datasets. - */ final readonly class LazyPipeline implements Pipeline { /** @var Operation[] */ private array $stages; - /** - * @param Operation[] $stages - */ - private function __construct(private iterable $source, array $stages = []) + private function __construct(private iterable|Closure $source, array $stages = []) { $this->stages = $stages; } @@ -31,6 +23,11 @@ public static function from(iterable $source): LazyPipeline return new LazyPipeline(source: $source); } + public static function fromClosure(Closure $factory): LazyPipeline + { + return new LazyPipeline(source: $factory); + } + public function pipe(Operation $operation): Pipeline { $stages = $this->stages; @@ -57,7 +54,9 @@ public function getBy(int $index, mixed $defaultValueIfNotFound = null): mixed public function process(): Generator { - $elements = $this->source; + $elements = $this->source instanceof Closure + ? ($this->source)() + : $this->source; foreach ($this->stages as $stage) { $elements = $stage->apply(elements: $elements); diff --git a/src/Internal/Operations/Transforming/Rearrange.php b/src/Internal/Operations/Transforming/Rearrange.php index e36bb30..2716e6c 100644 --- a/src/Internal/Operations/Transforming/Rearrange.php +++ b/src/Internal/Operations/Transforming/Rearrange.php @@ -29,7 +29,7 @@ public function apply(iterable $elements): Generator match ($this->order) { Order::ASCENDING_KEY => ksort($materialized), - Order::DESCENDING_KEY => krsort($materialized), + Order::DESCENDING_KEY => krsort($materialized), Order::ASCENDING_VALUE => uasort($materialized, $comparator), Order::DESCENDING_VALUE => uasort( $materialized, diff --git a/tests/CollectionTest.php b/tests/CollectionTest.php index 0453aa1..df7d043 100644 --- a/tests/CollectionTest.php +++ b/tests/CollectionTest.php @@ -605,4 +605,62 @@ public function testConcatCarriersCollections(): void $all->toArray(keyPreservation: KeyPreservation::DISCARD) ); } + + public function testClosureAndLazyAndEagerProduceSameResults(): void + { + /** @Given a set of elements */ + $elements = [5, 3, 1, 4, 2]; + + /** @And a filter predicate for values greater than 2 */ + $filter = static fn(int $value): bool => $value > 2; + + /** @And a map transformation that multiplies by 10 */ + $map = static fn(int $value): int => $value * 10; + + /** @When applying filter, map and sort on a closure-backed collection */ + $closureResult = Collection::createLazyFromClosure(factory: static function () use ($elements): array { + return $elements; + }) + ->filter(predicates: $filter) + ->map(transformations: $map) + ->sort(order: SortOrder::ASCENDING_VALUE) + ->toArray(keyPreservation: KeyPreservation::DISCARD); + + /** @And applying the same operations on a lazy collection */ + $lazyResult = Collection::createLazyFrom(elements: $elements) + ->filter(predicates: $filter) + ->map(transformations: $map) + ->sort(order: SortOrder::ASCENDING_VALUE) + ->toArray(keyPreservation: KeyPreservation::DISCARD); + + /** @And applying the same operations on an eager collection */ + $eagerResult = Collection::createFrom(elements: $elements) + ->filter(predicates: $filter) + ->map(transformations: $map) + ->sort(order: SortOrder::ASCENDING_VALUE) + ->toArray(keyPreservation: KeyPreservation::DISCARD); + + /** @Then all three should produce identical arrays */ + self::assertSame($closureResult, $lazyResult); + self::assertSame($closureResult, $eagerResult); + } + + public function testClosureBackedCarriersPreservesType(): void + { + /** @Given a closure-backed Carriers collection */ + $carriers = Carriers::createLazyFromClosure(factory: static function (): array { + return ['dhl', 'fedex', 'ups']; + }); + + /** @When mapping to uppercase */ + $actual = $carriers->map( + transformations: static fn(string $name): string => strtoupper($name) + ); + + /** @Then the result should still be an instance of Carriers */ + self::assertInstanceOf(Carriers::class, $actual); + + /** @And the carriers should be uppercased */ + self::assertSame(['DHL', 'FEDEX', 'UPS'], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + } } diff --git a/tests/EagerCollectionTest.php b/tests/EagerCollectionTest.php index f8de439..40f7079 100644 --- a/tests/EagerCollectionTest.php +++ b/tests/EagerCollectionTest.php @@ -58,6 +58,28 @@ public function testFromGenerator(): void self::assertSame(3, $collection->count()); } + public function testFromGeneratorReiteratesSuccessfully(): void + { + /** @Given a generator that yields three elements */ + $generator = (static function (): Generator { + yield 1; + yield 2; + yield 3; + })(); + + /** @When creating an eager collection from the generator */ + $collection = Collection::createFrom(elements: $generator); + + /** @And consuming the collection via count */ + $count = $collection->count(); + + /** @Then the count should be 3 */ + self::assertSame(3, $count); + + /** @And a subsequent toArray should still return all elements */ + self::assertSame([1, 2, 3], $collection->toArray()); + } + public function testAdd(): void { /** @Given an eager collection with three elements */ @@ -233,15 +255,12 @@ public function testEach(): void $sum = 0; /** @When using each to accumulate the sum */ - $actual = $collection->each(actions: function (int $value) use (&$sum): void { + $collection->each(actions: function (int $value) use (&$sum): void { $sum += $value; }); /** @Then the sum should be 6 */ self::assertSame(6, $sum); - - /** @And the returned collection should be the same instance */ - self::assertSame($collection, $actual); } public function testEqualsWithIdenticalCollections(): void @@ -985,7 +1004,7 @@ public function testChainedOperationsWithObjects(): void /** @And a variable to accumulate the total discounted value */ $totalDiscounted = 0.0; - /** @When chaining filter, map, removeAll, sort and each */ + /** @When chaining filter, map, removeAll and sort */ $actual = $collection ->filter(predicates: static fn(Amount $amount): bool => $amount->value >= 100) ->map(transformations: static fn(Amount $amount): Amount => new Amount( @@ -996,10 +1015,12 @@ public function testChainedOperationsWithObjects(): void ->sort( order: Order::ASCENDING_VALUE, comparator: static fn(Amount $first, Amount $second): int => $first->value <=> $second->value - ) - ->each(actions: function (Amount $amount) use (&$totalDiscounted): void { - $totalDiscounted += $amount->value; - }); + ); + + /** @And accumulating the total discounted value via each */ + $actual->each(actions: function (Amount $amount) use (&$totalDiscounted): void { + $totalDiscounted += $amount->value; + }); /** @Then the final collection should contain exactly three elements */ self::assertCount(3, $actual); diff --git a/tests/LazyCollectionTest.php b/tests/LazyCollectionTest.php index dfe324a..3d1c409 100644 --- a/tests/LazyCollectionTest.php +++ b/tests/LazyCollectionTest.php @@ -233,15 +233,12 @@ public function testEach(): void $sum = 0; /** @When using each to accumulate the sum */ - $actual = $collection->each(actions: function (int $value) use (&$sum): void { + $collection->each(actions: function (int $value) use (&$sum): void { $sum += $value; }); /** @Then the sum should be 6 */ self::assertSame(6, $sum); - - /** @And the returned collection should be the same instance */ - self::assertSame($collection, $actual); } public function testEqualsWithIdenticalCollections(): void @@ -985,7 +982,7 @@ public function testChainedOperationsWithObjects(): void /** @And a variable to accumulate the total discounted value */ $totalDiscounted = 0.0; - /** @When chaining filter, map, removeAll, sort and each */ + /** @When chaining filter, map, removeAll and sort */ $actual = $collection ->filter(predicates: static fn(Amount $amount): bool => $amount->value >= 100) ->map(transformations: static fn(Amount $amount): Amount => new Amount( @@ -996,10 +993,12 @@ public function testChainedOperationsWithObjects(): void ->sort( order: Order::ASCENDING_VALUE, comparator: static fn(Amount $first, Amount $second): int => $first->value <=> $second->value - ) - ->each(actions: function (Amount $amount) use (&$totalDiscounted): void { - $totalDiscounted += $amount->value; - }); + ); + + /** @And accumulating the total discounted value via each */ + $actual->each(actions: function (Amount $amount) use (&$totalDiscounted): void { + $totalDiscounted += $amount->value; + }); /** @Then the final collection should contain exactly three elements */ self::assertCount(3, $actual); @@ -1040,4 +1039,169 @@ public function testChainedOperationsWithIntegers(): void /** @Then the sum should be 171700 */ self::assertSame(171700, $sum); } + + public function testFromClosure(): void + { + /** @Given a closure that yields three elements */ + $factory = static function (): Generator { + yield 1; + yield 2; + yield 3; + }; + + /** @When creating a lazy collection from the closure */ + $collection = Collection::createLazyFromClosure(factory: $factory); + + /** @Then the collection should contain all three elements */ + self::assertSame(3, $collection->count()); + + /** @And the array should match the expected elements */ + self::assertSame([1, 2, 3], $collection->toArray()); + } + + public function testFromClosureReiteratesSuccessfully(): void + { + /** @Given a closure that yields elements */ + $collection = Collection::createLazyFromClosure(factory: static function (): Generator { + yield 10; + yield 20; + yield 30; + }); + + /** @When consuming the collection via count */ + $count = $collection->count(); + + /** @Then the count should be 3 */ + self::assertSame(3, $count); + + /** @And a subsequent toArray should still return all elements */ + self::assertSame([10, 20, 30], $collection->toArray()); + + /** @And first should return the first element */ + self::assertSame(10, $collection->first()); + + /** @And last should return the last element */ + self::assertSame(30, $collection->last()); + } + + public function testFromClosureWithEmptyClosure(): void + { + /** @Given a closure that yields nothing */ + $collection = Collection::createLazyFromClosure(factory: static function (): Generator { + yield from []; + }); + + /** @When checking the collection */ + $isEmpty = $collection->isEmpty(); + + /** @Then the collection should be empty */ + self::assertTrue($isEmpty); + + /** @And the count should be zero */ + self::assertSame(0, $collection->count()); + } + + public function testFromClosureWithChainedOperations(): void + { + /** @Given a closure-backed collection with integers */ + $collection = Collection::createLazyFromClosure(factory: static function (): Generator { + yield from [5, 3, 1, 4, 2]; + }); + + /** @When chaining filter, map and sort */ + $actual = $collection + ->filter(predicates: static fn(int $value): bool => $value > 2) + ->map(transformations: static fn(int $value): int => $value * 10) + ->sort(order: Order::ASCENDING_VALUE); + + /** @Then the result should contain the filtered, mapped and sorted values */ + self::assertSame([30, 40, 50], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + } + + public function testFromClosureWithObjects(): void + { + /** @Given a closure that yields Amount objects */ + $collection = Collection::createLazyFromClosure(factory: static function (): Generator { + yield new Amount(value: 100.00, currency: Currency::USD); + yield new Amount(value: 200.00, currency: Currency::USD); + yield new Amount(value: 300.00, currency: Currency::USD); + }); + + /** @When reducing to sum all amounts */ + $total = $collection->reduce( + accumulator: static fn(float $carry, Amount $amount): float => $carry + $amount->value, + initial: 0.0 + ); + + /** @Then the total should be 600 */ + self::assertSame(600.00, $total); + } + + public function testFromClosureGetByIndex(): void + { + /** @Given a closure-backed collection */ + $collection = Collection::createLazyFromClosure(factory: static function (): Generator { + yield 'alpha'; + yield 'beta'; + yield 'gamma'; + }); + + /** @When retrieving element at index 1 */ + $actual = $collection->getBy(index: 1); + + /** @Then it should return the second element */ + self::assertSame('beta', $actual); + } + + public function testFromClosureContainsElement(): void + { + /** @Given a closure-backed collection */ + $collection = Collection::createLazyFromClosure(factory: static function (): Generator { + yield 'alpha'; + yield 'beta'; + yield 'gamma'; + }); + + /** @When checking if the collection contains an existing element */ + $containsBeta = $collection->contains(element: 'beta'); + + /** @Then it should return true */ + self::assertTrue($containsBeta); + + /** @And checking for a non-existing element should return false */ + self::assertFalse($collection->contains(element: 'delta')); + } + + public function testFromClosureAdd(): void + { + /** @Given a closure-backed collection */ + $collection = Collection::createLazyFromClosure(factory: static function (): Generator { + yield 1; + yield 2; + }); + + /** @When adding elements */ + $actual = $collection->add(3, 4); + + /** @Then all elements should be present */ + self::assertSame([1, 2, 3, 4], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + } + + public function testFromClosureMerge(): void + { + /** @Given a closure-backed collection */ + $closureCollection = Collection::createLazyFromClosure(factory: static function (): Generator { + yield 1; + yield 2; + }); + + /** @And an eager collection */ + $eagerCollection = Collection::createFrom(elements: [3, 4]); + + /** @When merging them */ + $actual = $closureCollection->merge(other: $eagerCollection); + + /** @Then the result should contain all elements */ + self::assertSame([1, 2, 3, 4], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + } } From 79f7fdbf74bddecfef74cd1eec511e285ffd4715 Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Wed, 15 Apr 2026 15:30:15 -0300 Subject: [PATCH 02/15] refactor: Inline private toGenerator method in Equality --- .../Operations/Resolving/Equality.php | 66 +++++++++---------- 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/src/Internal/Operations/Resolving/Equality.php b/src/Internal/Operations/Resolving/Equality.php index 30c68e0..c86d950 100644 --- a/src/Internal/Operations/Resolving/Equality.php +++ b/src/Internal/Operations/Resolving/Equality.php @@ -8,48 +8,48 @@ use TinyBlocks\Collection\Collectible; final readonly class Equality -{ - public static function areSame(mixed $element, mixed $other): bool { - if (is_object($element) !== is_object($other)) { - return false; + public static function areSame(mixed $element, mixed $other): bool + { + if (is_object($element) !== is_object($other)) { + return false; + } + + return is_object($element) + ? $element == $other + : $element === $other; } - return is_object($element) - ? $element == $other - : $element === $other; - } - public static function exists(iterable $elements, mixed $element): bool - { - foreach ($elements as $current) { - if (Equality::areSame(element: $current, other: $element)) { - return true; - } - } + { + foreach ($elements as $current) { + if (Equality::areSame(element: $current, other: $element)) { + return true; + } + } - return false; - } + return false; + } public static function compareAll(iterable $elements, Collectible $other): bool - { - $iteratorA = Equality::toGenerator(iterable: $elements); - $iteratorB = Equality::toGenerator(iterable: $other); + { + $iteratorA = (static function () use ($elements): Generator { + yield from $elements; + })(); - while ($iteratorA->valid() && $iteratorB->valid()) { - if (!Equality::areSame(element: $iteratorA->current(), other: $iteratorB->current())) { - return false; - } + $iteratorB = (static function () use ($other): Generator { + yield from $other; + })(); - $iteratorA->next(); - $iteratorB->next(); - } + while ($iteratorA->valid() && $iteratorB->valid()) { + if (!Equality::areSame(element: $iteratorA->current(), other: $iteratorB->current())) { + return false; + } - return !$iteratorA->valid() && !$iteratorB->valid(); - } + $iteratorA->next(); + $iteratorB->next(); + } - private static function toGenerator(iterable $iterable): Generator - { - yield from $iterable; + return !$iteratorA->valid() && !$iteratorB->valid(); + } } -} From 7198460226881fbd88a9934ee1f00d7d3679cd82 Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Wed, 15 Apr 2026 15:34:03 -0300 Subject: [PATCH 03/15] refactor: Make EagerPipeline truly eager and final readonly --- src/Internal/EagerPipeline.php | 70 +++++++++++----------------------- 1 file changed, 23 insertions(+), 47 deletions(-) diff --git a/src/Internal/EagerPipeline.php b/src/Internal/EagerPipeline.php index cd94429..99cdd5b 100644 --- a/src/Internal/EagerPipeline.php +++ b/src/Internal/EagerPipeline.php @@ -7,64 +7,40 @@ use Generator; use TinyBlocks\Collection\Internal\Operations\Operation; -final class EagerPipeline implements Pipeline -{ - private ?array $materialized = null; - - /** @var Operation[] */ - private readonly array $stages; - - private function __construct(private readonly iterable $source, array $stages = []) +final readonly class EagerPipeline implements Pipeline { - $this->stages = $stages; - } + private function __construct(private array $elements) + { + } public static function from(iterable $source): EagerPipeline - { - return new EagerPipeline(source: $source); - } + { + return new EagerPipeline(elements: is_array($source) ? $source : iterator_to_array($source)); + } public function pipe(Operation $operation): Pipeline - { - $stages = $this->stages; - $stages[] = $operation; + { + $elements = $operation->apply(elements: $this->elements); - return new EagerPipeline(source: $this->source, stages: $stages); - } + return new EagerPipeline( + elements: is_array($elements) ? $elements : iterator_to_array($elements) + ); + } public function count(): int - { - return count($this->elements()); - } + { + return count($this->elements); + } public function getBy(int $index, mixed $defaultValueIfNotFound = null): mixed - { - $elements = $this->elements(); - - return array_key_exists($index, $elements) - ? $elements[$index] - : $defaultValueIfNotFound; - } - - public function process(): Generator - { - yield from $this->elements(); - } - - private function elements(): array - { - if (!is_null($this->materialized)) { - return $this->materialized; + { + return array_key_exists($index, $this->elements) + ? $this->elements[$index] + : $defaultValueIfNotFound; } - $elements = $this->source; - - foreach ($this->stages as $stage) { - $elements = $stage->apply(elements: $elements); + public function process(): Generator + { + yield from $this->elements; } - - $this->materialized = is_array($elements) ? $elements : iterator_to_array($elements); - - return $this->materialized; } -} From b5d16712c74ecd8bab742e0ae95841994bd6e934 Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Wed, 15 Apr 2026 15:37:32 -0300 Subject: [PATCH 04/15] refactor: Inline private pipeTo method in Collection --- src/Collection.php | 176 ++++++++++++++++++++++----------------------- 1 file changed, 85 insertions(+), 91 deletions(-) diff --git a/src/Collection.php b/src/Collection.php index 8f7a2f3..2799e04 100644 --- a/src/Collection.php +++ b/src/Collection.php @@ -7,7 +7,6 @@ use Closure; use TinyBlocks\Collection\Internal\EagerPipeline; use TinyBlocks\Collection\Internal\LazyPipeline; -use TinyBlocks\Collection\Internal\Operations\Operation; use TinyBlocks\Collection\Internal\Operations\Resolving\Each; use TinyBlocks\Collection\Internal\Operations\Resolving\Equality; use TinyBlocks\Collection\Internal\Operations\Resolving\Find; @@ -31,150 +30,145 @@ use Traversable; class Collection implements Collectible, IterableMapper -{ - use IterableMappability; + { + use IterableMappability; private function __construct(private readonly Pipeline $pipeline) - { - } + { + } public static function createFrom(iterable $elements): static - { - return new static(pipeline: EagerPipeline::from(source: $elements)); - } + { + return new static(pipeline: EagerPipeline::from(source: $elements)); + } public static function createFromEmpty(): static - { - return static::createFrom(elements: []); - } + { + return static::createFrom(elements: []); + } public static function createLazyFrom(iterable $elements): static - { - return new static(pipeline: LazyPipeline::from(source: $elements)); - } + { + return new static(pipeline: LazyPipeline::from(source: $elements)); + } public static function createLazyFromEmpty(): static - { - return static::createLazyFrom(elements: []); - } + { + return static::createLazyFrom(elements: []); + } public static function createLazyFromClosure(Closure $factory): static - { - return new static(pipeline: LazyPipeline::fromClosure(factory: $factory)); - } + { + return new static(pipeline: LazyPipeline::fromClosure(factory: $factory)); + } public function getIterator(): Traversable - { - yield from $this->pipeline->process(); - } + { + yield from $this->pipeline->process(); + } public function add(mixed ...$elements): static - { - return $this->pipeTo(operation: Add::these(newElements: $elements)); - } + { + return new static(pipeline: $this->pipeline->pipe(operation: Add::these(newElements: $elements))); + } public function merge(Collectible $other): static - { - return $this->pipeTo(operation: Merge::with(other: $other)); - } + { + return new static(pipeline: $this->pipeline->pipe(operation: Merge::with(other: $other))); + } public function contains(mixed $element): bool - { - return Equality::exists(elements: $this, element: $element); - } + { + return Equality::exists(elements: $this, element: $element); + } public function count(): int - { - return $this->pipeline->count(); - } + { + return $this->pipeline->count(); + } public function findBy(Closure ...$predicates): mixed - { - return Find::firstMatch(elements: $this, predicates: $predicates); - } + { + return Find::firstMatch(elements: $this, predicates: $predicates); + } public function each(Closure ...$actions): void - { - Each::execute(elements: $this, actions: $actions); - } + { + Each::execute(elements: $this, actions: $actions); + } public function equals(Collectible $other): bool - { - return Equality::compareAll(elements: $this, other: $other); - } + { + return Equality::compareAll(elements: $this, other: $other); + } public function remove(mixed $element): static - { - return $this->pipeTo(operation: Remove::element(element: $element)); - } + { + return new static(pipeline: $this->pipeline->pipe(operation: Remove::element(element: $element))); + } public function removeAll(?Closure $predicate = null): static - { - return $this->pipeTo(operation: RemoveAll::matching(predicate: $predicate)); - } + { + return new static(pipeline: $this->pipeline->pipe(operation: RemoveAll::matching(predicate: $predicate))); + } public function filter(?Closure ...$predicates): static - { - return $this->pipeTo(operation: Filter::matching(...$predicates)); - } + { + return new static(pipeline: $this->pipeline->pipe(operation: Filter::matching(...$predicates))); + } public function first(mixed $defaultValueIfNotFound = null): mixed - { - return First::from(elements: $this, defaultValueIfNotFound: $defaultValueIfNotFound); - } + { + return First::from(elements: $this, defaultValueIfNotFound: $defaultValueIfNotFound); + } public function flatten(): static - { - return $this->pipeTo(operation: FlatMap::oneLevel()); - } + { + return new static(pipeline: $this->pipeline->pipe(operation: FlatMap::oneLevel())); + } public function getBy(int $index, mixed $defaultValueIfNotFound = null): mixed - { - return $this->pipeline->getBy(index: $index, defaultValueIfNotFound: $defaultValueIfNotFound); - } + { + return $this->pipeline->getBy(index: $index, defaultValueIfNotFound: $defaultValueIfNotFound); + } public function groupBy(Closure $classifier): static - { - return $this->pipeTo(operation: GroupInto::by(classifier: $classifier)); - } + { + return new static(pipeline: $this->pipeline->pipe(operation: GroupInto::by(classifier: $classifier))); + } public function isEmpty(): bool - { - return First::isAbsent(elements: $this); - } + { + return First::isAbsent(elements: $this); + } public function joinToString(string $separator): string - { - return Join::elements(elements: $this, separator: $separator); - } + { + return Join::elements(elements: $this, separator: $separator); + } public function last(mixed $defaultValueIfNotFound = null): mixed - { - return Last::from(elements: $this, defaultValueIfNotFound: $defaultValueIfNotFound); - } + { + return Last::from(elements: $this, defaultValueIfNotFound: $defaultValueIfNotFound); + } public function map(Closure ...$transformations): static - { - return $this->pipeTo(operation: Map::using(...$transformations)); - } + { + return new static(pipeline: $this->pipeline->pipe(operation: Map::using(...$transformations))); + } public function reduce(Closure $accumulator, mixed $initial): mixed - { - return Reduce::from(elements: $this, accumulator: $accumulator, initial: $initial); - } + { + return Reduce::from(elements: $this, accumulator: $accumulator, initial: $initial); + } public function sort(Order $order = Order::ASCENDING_KEY, ?Closure $comparator = null): static - { - return $this->pipeTo(operation: Rearrange::by(order: $order, comparator: $comparator)); - } + { + return new static(pipeline: $this->pipeline->pipe(operation: Rearrange::by(order: $order, comparator: $comparator))); + } public function slice(int $offset, int $length = -1): static - { - return $this->pipeTo(operation: Segment::from(offset: $offset, length: $length)); - } - - private function pipeTo(Operation $operation): static - { - return new static(pipeline: $this->pipeline->pipe(operation: $operation)); + { + return new static(pipeline: $this->pipeline->pipe(operation: Segment::from(offset: $offset, length: $length))); + } } -} From 781c0d80270b0ebc0dd395ff0722eb844e46d4ce Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Wed, 15 Apr 2026 15:38:53 -0300 Subject: [PATCH 05/15] refactor: Remove PHPDoc from Order enum --- src/Order.php | 34 ++++++---------------------------- 1 file changed, 6 insertions(+), 28 deletions(-) diff --git a/src/Order.php b/src/Order.php index 11aa1ac..544d75e 100644 --- a/src/Order.php +++ b/src/Order.php @@ -4,32 +4,10 @@ namespace TinyBlocks\Collection; -/** - * Defines the sorting strategy applied to a collection. - * - * Key-based strategies sort by the element's key (index). - * Value-based strategies sort by the element's value using - * a comparator or the spaceship operator as default. - */ enum Order -{ - /** - * Sorts elements by key in ascending order. - */ - case ASCENDING_KEY; - - /** - * Sorts elements by key in descending order. - */ - case DESCENDING_KEY; - - /** - * Sorts elements by value in ascending order. - */ - case ASCENDING_VALUE; - - /** - * Sorts elements by value in descending order. - */ - case DESCENDING_VALUE; -} + { + case ASCENDING_KEY; + case DESCENDING_KEY; + case ASCENDING_VALUE; + case DESCENDING_VALUE; + } From 45ac25077bdbbb60541647f58b193d0654b6abb7 Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Wed, 15 Apr 2026 15:40:48 -0300 Subject: [PATCH 06/15] docs: Add missing badges to README --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index b96abea..76d2e98 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,10 @@ # Collection [![License](https://img.shields.io/badge/license-MIT-green)](LICENSE) +[![CI](https://github.com/tiny-blocks/collection/actions/workflows/ci.yml/badge.svg)](https://github.com/tiny-blocks/collection/actions/workflows/ci.yml) +[![Coverage](https://codecov.io/gh/tiny-blocks/collection/branch/main/graph/badge.svg)](https://codecov.io/gh/tiny-blocks/collection) +[![Latest Version](https://img.shields.io/packagist/v/tiny-blocks/collection)](https://packagist.org/packages/tiny-blocks/collection) +[![PHP Version](https://img.shields.io/packagist/dependency-v/tiny-blocks/collection/php)](https://packagist.org/packages/tiny-blocks/collection) * [Overview](#overview) * [Installation](#installation) From ce4e4c0067fd667041e52e6398c51686f23ff79d Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Wed, 15 Apr 2026 15:47:23 -0300 Subject: [PATCH 07/15] fix: Correct indentation in EagerPipeline --- src/Internal/EagerPipeline.php | 46 ++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/src/Internal/EagerPipeline.php b/src/Internal/EagerPipeline.php index 99cdd5b..930f43c 100644 --- a/src/Internal/EagerPipeline.php +++ b/src/Internal/EagerPipeline.php @@ -7,6 +7,52 @@ use Generator; use TinyBlocks\Collection\Internal\Operations\Operation; +final readonly class EagerPipeline implements Pipeline +{ + private function __construct(private array $elements) + { + } + + public static function from(iterable $source): EagerPipeline + { + return new EagerPipeline(elements: is_array($source) ? $source : iterator_to_array($source)); + } + + public function pipe(Operation $operation): Pipeline + { + $elements = $operation->apply(elements: $this->elements); + + return new EagerPipeline( + elements: is_array($elements) ? $elements : iterator_to_array($elements) + ); + } + + public function count(): int + { + return count($this->elements); + } + + public function getBy(int $index, mixed $defaultValueIfNotFound = null): mixed + { + return array_key_exists($index, $this->elements) + ? $this->elements[$index] + : $defaultValueIfNotFound; + } + + public function process(): Generator + { + yield from $this->elements; + } +} + Date: Wed, 15 Apr 2026 15:50:59 -0300 Subject: [PATCH 08/15] fix: Correct indentation in Equality --- .../Operations/Resolving/Equality.php | 66 +++++++++---------- 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/src/Internal/Operations/Resolving/Equality.php b/src/Internal/Operations/Resolving/Equality.php index c86d950..53d316e 100644 --- a/src/Internal/Operations/Resolving/Equality.php +++ b/src/Internal/Operations/Resolving/Equality.php @@ -8,48 +8,48 @@ use TinyBlocks\Collection\Collectible; final readonly class Equality +{ + public static function areSame(mixed $element, mixed $other): bool { - public static function areSame(mixed $element, mixed $other): bool - { - if (is_object($element) !== is_object($other)) { - return false; - } - - return is_object($element) - ? $element == $other - : $element === $other; + if (is_object($element) !== is_object($other)) { + return false; } - public static function exists(iterable $elements, mixed $element): bool - { - foreach ($elements as $current) { - if (Equality::areSame(element: $current, other: $element)) { - return true; - } - } + return is_object($element) + ? $element == $other + : $element === $other; + } - return false; + public static function exists(iterable $elements, mixed $element): bool + { + foreach ($elements as $current) { + if (Equality::areSame(element: $current, other: $element)) { + return true; + } } - public static function compareAll(iterable $elements, Collectible $other): bool - { - $iteratorA = (static function () use ($elements): Generator { - yield from $elements; - })(); + return false; + } - $iteratorB = (static function () use ($other): Generator { - yield from $other; - })(); + public static function compareAll(iterable $elements, Collectible $other): bool + { + $iteratorA = (static function () use ($elements): Generator { + yield from $elements; + })(); - while ($iteratorA->valid() && $iteratorB->valid()) { - if (!Equality::areSame(element: $iteratorA->current(), other: $iteratorB->current())) { - return false; - } + $iteratorB = (static function () use ($other): Generator { + yield from $other; + })(); - $iteratorA->next(); - $iteratorB->next(); - } + while ($iteratorA->valid() && $iteratorB->valid()) { + if (!Equality::areSame(element: $iteratorA->current(), other: $iteratorB->current())) { + return false; + } - return !$iteratorA->valid() && !$iteratorB->valid(); + $iteratorA->next(); + $iteratorB->next(); } + + return !$iteratorA->valid() && !$iteratorB->valid(); } +} From 2117e24dda48661bcbdff43e70de2a601e2bdd86 Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Wed, 15 Apr 2026 15:57:05 -0300 Subject: [PATCH 09/15] fix: Correct indentation in Collection --- src/Collection.php | 170 ++++++++++++++++++++++----------------------- 1 file changed, 85 insertions(+), 85 deletions(-) diff --git a/src/Collection.php b/src/Collection.php index 2799e04..de9e148 100644 --- a/src/Collection.php +++ b/src/Collection.php @@ -30,145 +30,145 @@ use Traversable; class Collection implements Collectible, IterableMapper - { - use IterableMappability; +{ + use IterableMappability; private function __construct(private readonly Pipeline $pipeline) - { - } + { + } public static function createFrom(iterable $elements): static - { - return new static(pipeline: EagerPipeline::from(source: $elements)); - } + { + return new static(pipeline: EagerPipeline::from(source: $elements)); + } public static function createFromEmpty(): static - { - return static::createFrom(elements: []); - } + { + return static::createFrom(elements: []); + } public static function createLazyFrom(iterable $elements): static - { - return new static(pipeline: LazyPipeline::from(source: $elements)); - } + { + return new static(pipeline: LazyPipeline::from(source: $elements)); + } public static function createLazyFromEmpty(): static - { - return static::createLazyFrom(elements: []); - } + { + return static::createLazyFrom(elements: []); + } public static function createLazyFromClosure(Closure $factory): static - { - return new static(pipeline: LazyPipeline::fromClosure(factory: $factory)); - } + { + return new static(pipeline: LazyPipeline::fromClosure(factory: $factory)); + } public function getIterator(): Traversable - { - yield from $this->pipeline->process(); - } + { + yield from $this->pipeline->process(); + } public function add(mixed ...$elements): static - { - return new static(pipeline: $this->pipeline->pipe(operation: Add::these(newElements: $elements))); - } + { + return new static(pipeline: $this->pipeline->pipe(operation: Add::these(newElements: $elements))); + } public function merge(Collectible $other): static - { - return new static(pipeline: $this->pipeline->pipe(operation: Merge::with(other: $other))); - } + { + return new static(pipeline: $this->pipeline->pipe(operation: Merge::with(other: $other))); + } public function contains(mixed $element): bool - { - return Equality::exists(elements: $this, element: $element); - } + { + return Equality::exists(elements: $this, element: $element); + } public function count(): int - { - return $this->pipeline->count(); - } + { + return $this->pipeline->count(); + } public function findBy(Closure ...$predicates): mixed - { - return Find::firstMatch(elements: $this, predicates: $predicates); - } + { + return Find::firstMatch(elements: $this, predicates: $predicates); + } public function each(Closure ...$actions): void - { - Each::execute(elements: $this, actions: $actions); - } + { + Each::execute(elements: $this, actions: $actions); + } public function equals(Collectible $other): bool - { - return Equality::compareAll(elements: $this, other: $other); - } + { + return Equality::compareAll(elements: $this, other: $other); + } public function remove(mixed $element): static - { - return new static(pipeline: $this->pipeline->pipe(operation: Remove::element(element: $element))); - } + { + return new static(pipeline: $this->pipeline->pipe(operation: Remove::element(element: $element))); + } public function removeAll(?Closure $predicate = null): static - { - return new static(pipeline: $this->pipeline->pipe(operation: RemoveAll::matching(predicate: $predicate))); - } + { + return new static(pipeline: $this->pipeline->pipe(operation: RemoveAll::matching(predicate: $predicate))); + } public function filter(?Closure ...$predicates): static - { - return new static(pipeline: $this->pipeline->pipe(operation: Filter::matching(...$predicates))); - } + { + return new static(pipeline: $this->pipeline->pipe(operation: Filter::matching(...$predicates))); + } public function first(mixed $defaultValueIfNotFound = null): mixed - { - return First::from(elements: $this, defaultValueIfNotFound: $defaultValueIfNotFound); - } + { + return First::from(elements: $this, defaultValueIfNotFound: $defaultValueIfNotFound); + } public function flatten(): static - { - return new static(pipeline: $this->pipeline->pipe(operation: FlatMap::oneLevel())); - } + { + return new static(pipeline: $this->pipeline->pipe(operation: FlatMap::oneLevel())); + } public function getBy(int $index, mixed $defaultValueIfNotFound = null): mixed - { - return $this->pipeline->getBy(index: $index, defaultValueIfNotFound: $defaultValueIfNotFound); - } + { + return $this->pipeline->getBy(index: $index, defaultValueIfNotFound: $defaultValueIfNotFound); + } public function groupBy(Closure $classifier): static - { - return new static(pipeline: $this->pipeline->pipe(operation: GroupInto::by(classifier: $classifier))); - } + { + return new static(pipeline: $this->pipeline->pipe(operation: GroupInto::by(classifier: $classifier))); + } public function isEmpty(): bool - { - return First::isAbsent(elements: $this); - } + { + return First::isAbsent(elements: $this); + } public function joinToString(string $separator): string - { - return Join::elements(elements: $this, separator: $separator); - } + { + return Join::elements(elements: $this, separator: $separator); + } public function last(mixed $defaultValueIfNotFound = null): mixed - { - return Last::from(elements: $this, defaultValueIfNotFound: $defaultValueIfNotFound); - } + { + return Last::from(elements: $this, defaultValueIfNotFound: $defaultValueIfNotFound); + } public function map(Closure ...$transformations): static - { - return new static(pipeline: $this->pipeline->pipe(operation: Map::using(...$transformations))); - } + { + return new static(pipeline: $this->pipeline->pipe(operation: Map::using(...$transformations))); + } public function reduce(Closure $accumulator, mixed $initial): mixed - { - return Reduce::from(elements: $this, accumulator: $accumulator, initial: $initial); - } + { + return Reduce::from(elements: $this, accumulator: $accumulator, initial: $initial); + } public function sort(Order $order = Order::ASCENDING_KEY, ?Closure $comparator = null): static - { - return new static(pipeline: $this->pipeline->pipe(operation: Rearrange::by(order: $order, comparator: $comparator))); - } + { + return new static(pipeline: $this->pipeline->pipe(operation: Rearrange::by(order: $order, comparator: $comparator))); + } public function slice(int $offset, int $length = -1): static - { - return new static(pipeline: $this->pipeline->pipe(operation: Segment::from(offset: $offset, length: $length))); - } + { + return new static(pipeline: $this->pipeline->pipe(operation: Segment::from(offset: $offset, length: $length))); } +} From 4804523d42551b489c38b1d2058c12f986cda4f6 Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Wed, 15 Apr 2026 15:58:20 -0300 Subject: [PATCH 10/15] fix: Correct indentation in Order --- src/Order.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Order.php b/src/Order.php index 544d75e..926d012 100644 --- a/src/Order.php +++ b/src/Order.php @@ -5,9 +5,9 @@ namespace TinyBlocks\Collection; enum Order - { - case ASCENDING_KEY; - case DESCENDING_KEY; - case ASCENDING_VALUE; - case DESCENDING_VALUE; - } +{ + case ASCENDING_KEY; + case DESCENDING_KEY; + case ASCENDING_VALUE; + case DESCENDING_VALUE; +} From 9e239773f0863cbaa1f32cbaffb3cffab554a05d Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Wed, 15 Apr 2026 16:11:24 -0300 Subject: [PATCH 11/15] fix: Remove duplicate class in EagerPipeline --- src/Internal/EagerPipeline.php | 46 ---------------------------------- 1 file changed, 46 deletions(-) diff --git a/src/Internal/EagerPipeline.php b/src/Internal/EagerPipeline.php index 930f43c..5642568 100644 --- a/src/Internal/EagerPipeline.php +++ b/src/Internal/EagerPipeline.php @@ -44,49 +44,3 @@ public function process(): Generator yield from $this->elements; } } -apply(elements: $this->elements); - - return new EagerPipeline( - elements: is_array($elements) ? $elements : iterator_to_array($elements) - ); - } - - public function count(): int - { - return count($this->elements); - } - - public function getBy(int $index, mixed $defaultValueIfNotFound = null): mixed - { - return array_key_exists($index, $this->elements) - ? $this->elements[$index] - : $defaultValueIfNotFound; - } - - public function process(): Generator - { - yield from $this->elements; - } - } From 5ce29562cfd84b646c03356c1e9e3a64ce9d179a Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Wed, 15 Apr 2026 16:16:33 -0300 Subject: [PATCH 12/15] fix: Break long line in sort method to comply with 120-char limit --- src/Collection.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Collection.php b/src/Collection.php index de9e148..1f0e90d 100644 --- a/src/Collection.php +++ b/src/Collection.php @@ -164,7 +164,9 @@ public function reduce(Closure $accumulator, mixed $initial): mixed public function sort(Order $order = Order::ASCENDING_KEY, ?Closure $comparator = null): static { - return new static(pipeline: $this->pipeline->pipe(operation: Rearrange::by(order: $order, comparator: $comparator))); + $operation = Rearrange::by(order: $order, comparator: $comparator); + + return new static(pipeline: $this->pipeline->pipe(operation: $operation)); } public function slice(int $offset, int $length = -1): static From 3c5eadbdd5f15e2b4ea491bbae21f396364acc31 Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Wed, 15 Apr 2026 16:23:36 -0300 Subject: [PATCH 13/15] fix: Simplify EagerPipeline to fix phpstan analysis --- src/Internal/EagerPipeline.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Internal/EagerPipeline.php b/src/Internal/EagerPipeline.php index 5642568..8cbb9c1 100644 --- a/src/Internal/EagerPipeline.php +++ b/src/Internal/EagerPipeline.php @@ -15,16 +15,16 @@ private function __construct(private array $elements) public static function from(iterable $source): EagerPipeline { - return new EagerPipeline(elements: is_array($source) ? $source : iterator_to_array($source)); + $elements = is_array($source) ? $source : iterator_to_array($source); + + return new EagerPipeline(elements: $elements); } public function pipe(Operation $operation): Pipeline { - $elements = $operation->apply(elements: $this->elements); + $elements = iterator_to_array($operation->apply(elements: $this->elements)); - return new EagerPipeline( - elements: is_array($elements) ? $elements : iterator_to_array($elements) - ); + return new EagerPipeline(elements: $elements); } public function count(): int From adc1f15ebc450ad2ac59abd36418388b55aba098 Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Wed, 15 Apr 2026 16:34:25 -0300 Subject: [PATCH 14/15] docs: Update README with createLazyFromClosure and each void return --- README.md | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 76d2e98..ad1af35 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,23 @@ final class Invoices extends Collection
-### Writing +#### Creating from a closure + +The `createLazyFromClosure` method creates a lazy collection backed by a closure that produces an iterable. The +closure is invoked each time the collection is iterated, enabling safe re-iteration over generators or other single-use +iterables. + +```php +use TinyBlocks\Collection\Collection; + +$collection = Collection::createLazyFromClosure(factory: static function (): iterable { + yield 1; + yield 2; + yield 3; +}); +``` + +## Writing These methods enable adding, removing, and modifying elements in the Collection. @@ -329,7 +345,7 @@ These methods allow the Collection's elements to be transformed or converted int #### Applying actions without modifying elements - `each`: Executes actions on each element in the Collection without modification. - The method is helpful for performing side effects, such as logging or accumulating values. + This is a terminal operation that does not return the collection. It is useful for performing side effects, such as logging or accumulating values. ```php $collection->each(actions: static fn(Amount $amount): void => $total += $amount->value); @@ -424,7 +440,7 @@ recreate the `Collection`. - **Eager evaluation** (`createFrom` / `createFromEmpty`): Elements are materialized immediately into an array, enabling constant-time access by index, count, and repeated iteration. -- **Lazy evaluation** (`createLazyFrom` / `createLazyFromEmpty`): Elements are processed on-demand through generators, +- **Lazy evaluation** (`createLazyFrom` / `createLazyFromEmpty` / `createLazyFromClosure`): Elements are processed on-demand through generators, consuming memory only as each element is yielded. Ideal for large datasets or pipelines where not all elements need to be materialized. From ee86c8f5c730e4a3e7553123f52f54d748b62d4b Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Wed, 15 Apr 2026 16:45:05 -0300 Subject: [PATCH 15/15] fix: Refactor Filter and Rearrange classes for improved readability. --- README.md | 10 ++++------ src/Internal/Operations/Transforming/Filter.php | 4 ++-- src/Internal/Operations/Transforming/Rearrange.php | 2 +- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index ad1af35..4bf1eb2 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,6 @@ # Collection [![License](https://img.shields.io/badge/license-MIT-green)](LICENSE) -[![CI](https://github.com/tiny-blocks/collection/actions/workflows/ci.yml/badge.svg)](https://github.com/tiny-blocks/collection/actions/workflows/ci.yml) -[![Coverage](https://codecov.io/gh/tiny-blocks/collection/branch/main/graph/badge.svg)](https://codecov.io/gh/tiny-blocks/collection) -[![Latest Version](https://img.shields.io/packagist/v/tiny-blocks/collection)](https://packagist.org/packages/tiny-blocks/collection) -[![PHP Version](https://img.shields.io/packagist/dependency-v/tiny-blocks/collection/php)](https://packagist.org/packages/tiny-blocks/collection) * [Overview](#overview) * [Installation](#installation) @@ -345,7 +341,8 @@ These methods allow the Collection's elements to be transformed or converted int #### Applying actions without modifying elements - `each`: Executes actions on each element in the Collection without modification. - This is a terminal operation that does not return the collection. It is useful for performing side effects, such as logging or accumulating values. + This is a terminal operation that does not return the collection. It is useful for performing side effects, such as + logging or accumulating values. ```php $collection->each(actions: static fn(Amount $amount): void => $total += $amount->value); @@ -440,7 +437,8 @@ recreate the `Collection`. - **Eager evaluation** (`createFrom` / `createFromEmpty`): Elements are materialized immediately into an array, enabling constant-time access by index, count, and repeated iteration. -- **Lazy evaluation** (`createLazyFrom` / `createLazyFromEmpty` / `createLazyFromClosure`): Elements are processed on-demand through generators, +- **Lazy evaluation** (`createLazyFrom` / `createLazyFromEmpty` / `createLazyFromClosure`): Elements are processed + on-demand through generators, consuming memory only as each element is yielded. Ideal for large datasets or pipelines where not all elements need to be materialized. diff --git a/src/Internal/Operations/Transforming/Filter.php b/src/Internal/Operations/Transforming/Filter.php index debb77b..cb3c025 100644 --- a/src/Internal/Operations/Transforming/Filter.php +++ b/src/Internal/Operations/Transforming/Filter.php @@ -19,8 +19,8 @@ private function __construct(?Closure ...$predicates) $this->compiledPredicate = match (count($filtered)) { 0 => static fn(mixed $value, mixed $key): bool => (bool)$value, default => static fn(mixed $value, mixed $key): bool => array_all( - array: $filtered, - callback: static fn(Closure $predicate): bool => $predicate($value, $key) + $filtered, + static fn(Closure $predicate): bool => $predicate($value, $key) ), }; } diff --git a/src/Internal/Operations/Transforming/Rearrange.php b/src/Internal/Operations/Transforming/Rearrange.php index 2716e6c..6b573c0 100644 --- a/src/Internal/Operations/Transforming/Rearrange.php +++ b/src/Internal/Operations/Transforming/Rearrange.php @@ -22,7 +22,7 @@ public static function by(Order $order, ?Closure $comparator = null): Rearrange public function apply(iterable $elements): Generator { - $materialized = is_array($elements) ? $elements : iterator_to_array($elements, true); + $materialized = is_array($elements) ? $elements : iterator_to_array($elements); $comparator = $this->comparator ?? static fn(mixed $first, mixed $second): int => $first <=> $second;