From 07308d20db1c1948819fe44c5d95c52029c862ee Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Sat, 28 Mar 2026 18:37:21 -0300 Subject: [PATCH 1/3] feat: Add eager and lazy evaluation strategies with pipeline architecture. --- README.md | 161 ++- phpstan.neon.dist | 9 +- src/Collectible.php | 307 ++--- src/Collection.php | 170 +-- src/Internal/EagerPipeline.php | 41 + src/Internal/Iterators/LazyIterator.php | 68 -- .../Iterators/LazyIteratorAggregate.php | 43 - src/Internal/LazyPipeline.php | 52 + src/Internal/Operations/Aggregate/Reduce.php | 31 - src/Internal/Operations/Compare/Contains.php | 32 - src/Internal/Operations/Compare/Equals.php | 54 - src/Internal/Operations/Filter/Filter.php | 50 - .../Operations/ImmediateOperation.php | 12 - src/Internal/Operations/LazyOperation.php | 24 - src/Internal/Operations/Operation.php | 27 +- src/Internal/Operations/Order/Sort.php | 51 - src/Internal/Operations/Resolving/Each.php | 17 + .../Operations/Resolving/Equality.php | 55 + src/Internal/Operations/Resolving/Find.php | 21 + src/Internal/Operations/Resolving/First.php | 26 + src/Internal/Operations/Resolving/Get.php | 19 + src/Internal/Operations/Resolving/Join.php | 19 + src/Internal/Operations/Resolving/Last.php | 19 + src/Internal/Operations/Resolving/Reduce.php | 21 + src/Internal/Operations/Retrieve/Find.php | 31 - src/Internal/Operations/Retrieve/First.php | 28 - src/Internal/Operations/Retrieve/Get.php | 30 - src/Internal/Operations/Retrieve/Last.php | 30 - src/Internal/Operations/Retrieve/Slice.php | 75 -- src/Internal/Operations/Transform/Each.php | 32 - src/Internal/Operations/Transform/Flatten.php | 31 - src/Internal/Operations/Transform/GroupBy.php | 35 - .../Operations/Transform/JoinToString.php | 37 - src/Internal/Operations/Transforming/Add.php | 31 + .../Operations/Transforming/Filter.php | 43 + .../Operations/Transforming/FlatMap.php | 31 + .../Operations/Transforming/GroupInto.php | 33 + .../{Transform => Transforming}/Map.php | 8 +- .../Operations/Transforming/Merge.php | 31 + .../Operations/Transforming/Rearrange.php | 42 + .../Operations/Transforming/Remove.php | 30 + .../Operations/Transforming/RemoveAll.php | 32 + .../Operations/Transforming/Segment.php | 69 ++ src/Internal/Operations/Write/Add.php | 31 - src/Internal/Operations/Write/Create.php | 21 - src/Internal/Operations/Write/Merge.php | 31 - src/Internal/Operations/Write/Remove.php | 32 - src/Internal/Operations/Write/RemoveAll.php | 32 - src/Internal/Pipeline.php | 36 + src/Order.php | 14 +- tests/CollectionIteratorTest.php | 63 - tests/CollectionPerformanceTest.php | 297 ----- tests/CollectionTest.php | 648 ++++++++++- tests/EagerCollectionTest.php | 1016 +++++++++++++++++ tests/Internal/Iterators/LazyIteratorTest.php | 34 - .../CollectionReduceOperationTest.php | 126 -- .../CollectionContainsOperationTest.php | 87 -- .../Compare/CollectionEqualsOperationTest.php | 103 -- .../Filter/CollectionFilterOperationTest.php | 142 --- .../Order/CollectionSortOperationTest.php | 200 ---- .../Retrieve/CollectionFindOperationTest.php | 98 -- .../Retrieve/CollectionFirstOperationTest.php | 65 -- .../Retrieve/CollectionGetOperationTest.php | 178 --- .../Retrieve/CollectionLastOperationTest.php | 95 -- .../Retrieve/CollectionSliceOperationTest.php | 124 -- .../Transform/CollectionEachOperationTest.php | 85 -- .../CollectionFlattenOperationTest.php | 68 -- .../CollectionGroupByOperationTest.php | 98 -- .../Transform/CollectionJoinToStringTest.php | 95 -- .../Transform/CollectionMapOperationTest.php | 94 -- .../CollectionMapToArrayOperationTest.php | 94 -- .../CollectionMapToJsonOperationTest.php | 82 -- .../Write/CollectionAddOperationTest.php | 128 --- .../Write/CollectionCreateOperationTest.php | 47 - .../Write/CollectionMergeOperationTest.php | 116 -- .../Write/CollectionRemoveOperationTest.php | 125 -- tests/LazyCollectionTest.php | 1016 +++++++++++++++++ tests/Models/Carriers.php | 11 + tests/Models/InvoiceSummaries.php | 2 +- tests/Models/Invoices.php | 14 + 80 files changed, 3671 insertions(+), 3885 deletions(-) create mode 100644 src/Internal/EagerPipeline.php delete mode 100644 src/Internal/Iterators/LazyIterator.php delete mode 100644 src/Internal/Iterators/LazyIteratorAggregate.php create mode 100644 src/Internal/LazyPipeline.php delete mode 100644 src/Internal/Operations/Aggregate/Reduce.php delete mode 100644 src/Internal/Operations/Compare/Contains.php delete mode 100644 src/Internal/Operations/Compare/Equals.php delete mode 100644 src/Internal/Operations/Filter/Filter.php delete mode 100644 src/Internal/Operations/ImmediateOperation.php delete mode 100644 src/Internal/Operations/LazyOperation.php delete mode 100644 src/Internal/Operations/Order/Sort.php create mode 100644 src/Internal/Operations/Resolving/Each.php create mode 100644 src/Internal/Operations/Resolving/Equality.php create mode 100644 src/Internal/Operations/Resolving/Find.php create mode 100644 src/Internal/Operations/Resolving/First.php create mode 100644 src/Internal/Operations/Resolving/Get.php create mode 100644 src/Internal/Operations/Resolving/Join.php create mode 100644 src/Internal/Operations/Resolving/Last.php create mode 100644 src/Internal/Operations/Resolving/Reduce.php delete mode 100644 src/Internal/Operations/Retrieve/Find.php delete mode 100644 src/Internal/Operations/Retrieve/First.php delete mode 100644 src/Internal/Operations/Retrieve/Get.php delete mode 100644 src/Internal/Operations/Retrieve/Last.php delete mode 100644 src/Internal/Operations/Retrieve/Slice.php delete mode 100644 src/Internal/Operations/Transform/Each.php delete mode 100644 src/Internal/Operations/Transform/Flatten.php delete mode 100644 src/Internal/Operations/Transform/GroupBy.php delete mode 100644 src/Internal/Operations/Transform/JoinToString.php create mode 100644 src/Internal/Operations/Transforming/Add.php create mode 100644 src/Internal/Operations/Transforming/Filter.php create mode 100644 src/Internal/Operations/Transforming/FlatMap.php create mode 100644 src/Internal/Operations/Transforming/GroupInto.php rename src/Internal/Operations/{Transform => Transforming}/Map.php (70%) create mode 100644 src/Internal/Operations/Transforming/Merge.php create mode 100644 src/Internal/Operations/Transforming/Rearrange.php create mode 100644 src/Internal/Operations/Transforming/Remove.php create mode 100644 src/Internal/Operations/Transforming/RemoveAll.php create mode 100644 src/Internal/Operations/Transforming/Segment.php delete mode 100644 src/Internal/Operations/Write/Add.php delete mode 100644 src/Internal/Operations/Write/Create.php delete mode 100644 src/Internal/Operations/Write/Merge.php delete mode 100644 src/Internal/Operations/Write/Remove.php delete mode 100644 src/Internal/Operations/Write/RemoveAll.php create mode 100644 src/Internal/Pipeline.php delete mode 100644 tests/CollectionIteratorTest.php delete mode 100644 tests/CollectionPerformanceTest.php create mode 100644 tests/EagerCollectionTest.php delete mode 100644 tests/Internal/Iterators/LazyIteratorTest.php delete mode 100644 tests/Internal/Operations/Aggregate/CollectionReduceOperationTest.php delete mode 100644 tests/Internal/Operations/Compare/CollectionContainsOperationTest.php delete mode 100644 tests/Internal/Operations/Compare/CollectionEqualsOperationTest.php delete mode 100644 tests/Internal/Operations/Filter/CollectionFilterOperationTest.php delete mode 100644 tests/Internal/Operations/Order/CollectionSortOperationTest.php delete mode 100644 tests/Internal/Operations/Retrieve/CollectionFindOperationTest.php delete mode 100644 tests/Internal/Operations/Retrieve/CollectionFirstOperationTest.php delete mode 100644 tests/Internal/Operations/Retrieve/CollectionGetOperationTest.php delete mode 100644 tests/Internal/Operations/Retrieve/CollectionLastOperationTest.php delete mode 100644 tests/Internal/Operations/Retrieve/CollectionSliceOperationTest.php delete mode 100644 tests/Internal/Operations/Transform/CollectionEachOperationTest.php delete mode 100644 tests/Internal/Operations/Transform/CollectionFlattenOperationTest.php delete mode 100644 tests/Internal/Operations/Transform/CollectionGroupByOperationTest.php delete mode 100644 tests/Internal/Operations/Transform/CollectionJoinToStringTest.php delete mode 100644 tests/Internal/Operations/Transform/CollectionMapOperationTest.php delete mode 100644 tests/Internal/Operations/Transform/CollectionMapToArrayOperationTest.php delete mode 100644 tests/Internal/Operations/Transform/CollectionMapToJsonOperationTest.php delete mode 100644 tests/Internal/Operations/Write/CollectionAddOperationTest.php delete mode 100644 tests/Internal/Operations/Write/CollectionCreateOperationTest.php delete mode 100644 tests/Internal/Operations/Write/CollectionMergeOperationTest.php delete mode 100644 tests/Internal/Operations/Write/CollectionRemoveOperationTest.php create mode 100644 tests/LazyCollectionTest.php create mode 100644 tests/Models/Carriers.php diff --git a/README.md b/README.md index 8804001..1216edc 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ use TinyBlocks\Collection\Order; use TinyBlocks\Mapper\KeyPreservation; $collection = Collection::createFrom(elements: [1, 2, 3, 4, 5]) - ->add(elements: [6, 7]) + ->add(6, 7) ->filter(predicates: static fn(int $value): bool => $value > 3) ->sort(order: Order::ASCENDING_VALUE) ->map(transformations: static fn(int $value): int => $value * 2) @@ -76,6 +76,31 @@ $collection = Collection::createFrom(elements: [1, 2, 3, 4, 5]) # Output: [8, 10, 12, 14] ``` +### Extending Collection + +Domain collections should extend the `Collection` class to inherit all collection behavior: + +```php +reduce( + accumulator: static fn(float $carry, Invoice $invoice): float => $carry + $invoice->amount, + initial: 0.0 + ); + } +} +``` +
### Writing @@ -84,10 +109,10 @@ These methods enable adding, removing, and modifying elements in the Collection. #### Adding elements -- `add`: Adds one or more elements to the Collection. +- `add`: Returns a new collection with the specified elements appended. ```php - $collection->add(elements: [1, 2, 3]); + $collection->add(1, 2, 3); ``` ```php @@ -96,8 +121,7 @@ These methods enable adding, removing, and modifying elements in the Collection. #### Merging collections -- `merge`: Merges the elements of another Collectible into the current Collection lazily, without materializing either - collection. +- `merge`: Merges the elements of another Collectible into the current Collection. ```php $collectionA->merge(other: $collectionB); @@ -105,27 +129,27 @@ These methods enable adding, removing, and modifying elements in the Collection. #### Removing elements -- `remove`: Removes a specific element from the Collection. +- `remove`: Returns a new collection with all occurrences of the specified element removed. ```php $collection->remove(element: 1); ``` -- `removeAll`: Removes elements from the Collection. +- `removeAll`: Returns a new collection with elements removed.

- - **With a filter**: Removes only the elements that match the provided filter. + - **With a predicate**: Removes only the elements that satisfy the given predicate. ```php - $collection->removeAll(filter: static fn(Amount $amount): bool => $amount->value > 10.0); + $collection->removeAll(predicate: static fn(Amount $amount): bool => $amount->value > 10.0); ``` - - **Without a filter**: Removes all elements from the Collection. + - **Without a predicate**: Removes all elements from the Collection. ```php $collection->removeAll(); ``` -
+
### Filtering @@ -133,16 +157,16 @@ These methods enable filtering elements in the Collection based on specific cond #### Filter by predicate -- `filter`: Filters elements in the Collection. +- `filter`: Retains only elements satisfying all given predicates.

- - **With predicates**: Filter elements are based on the provided predicates. + - **With predicates**: Retains elements that satisfy the provided predicates. ```php $collection->filter(predicates: static fn(Amount $amount): bool => $amount->value > 100); ``` - - **Without predicates**: Removes all empty or false values (e.g., `null`, `false`, empty arrays). + - **Without predicates**: Removes all falsy values (e.g., `null`, `false`, `0`, `''`, empty arrays). ```php $collection->filter(); @@ -152,11 +176,11 @@ These methods enable filtering elements in the Collection based on specific cond ### Ordering -These methods enable sorting elements in the Collection based on the specified order and optional predicates. +These methods enable sorting elements in the Collection based on the specified order and optional comparator. -#### Sort by order and custom predicate +#### Sort by order and custom comparator -- `sort`: Sorts the Collection. +- `sort`: Returns a new sorted collection. ``` Order::ASCENDING_KEY: Sorts the collection in ascending order by key. @@ -173,13 +197,15 @@ These methods enable sorting elements in the Collection based on the specified o $collection->sort(order: Order::DESCENDING_VALUE); ``` - Sort the Collection using a custom predicate to determine how elements should be - compared. + Sort the Collection using a custom comparator to determine how elements should be compared. ```php use TinyBlocks\Collection\Order; - $collection->sort(order: Order::ASCENDING_VALUE, predicate: static fn(Amount $amount): float => $amount->value); + $collection->sort( + order: Order::ASCENDING_VALUE, + comparator: static fn(Amount $first, Amount $second): int => $first->value <=> $second->value + ); ```
@@ -197,54 +223,62 @@ elements, or finding elements that match a specific condition. $collection->count(); ``` +#### Check if empty + +- `isEmpty`: Determines whether the collection has no elements. + + ```php + $collection->isEmpty(); + ``` + #### Retrieve by condition -- `findBy`: Finds the first element that matches one or more predicates. +- `findBy`: Finds the first element that satisfies all given predicates, or returns null if no match is found. ```php $collection->findBy(predicates: static fn(CryptoCurrency $crypto): bool => $crypto->symbol === 'ETH'); ``` -
- #### Retrieve single elements - `first`: Retrieves the first element from the Collection or returns a default value if the Collection is empty. ```php - $collection->first(defaultValueIfNotFound: 'default'); + $collection->first(defaultValueIfNotFound: 'fallback'); ``` -- `getBy`: Retrieves an element by its index or returns a default value if the index is out of range. +- `getBy`: Retrieves an element by its zero-based index or returns a default value if the index is out of bounds. ```php - $collection->getBy(index: 0, defaultValueIfNotFound: 'default'); + $collection->getBy(index: 0, defaultValueIfNotFound: 'fallback'); ``` - `last`: Retrieves the last element from the Collection or returns a default value if the Collection is empty. ```php - $collection->last(defaultValueIfNotFound: 'default'); + $collection->last(defaultValueIfNotFound: 'fallback'); ``` -#### Retrieve collection elements +#### Retrieve collection segments -- `slice`: Extracts a portion of the collection, starting at the specified index and retrieving the specified number of - elements. - If length is negative, it excludes many elements from the end of the collection. - If length is not provided or set to -1, it returns all elements from the specified index to the end of the collection. +- `slice`: Extracts a contiguous segment of the collection, starting at the specified offset. + If length is negative, it excludes that many elements from the end. + If length is not provided or set to -1, it returns all elements from the specified offset to the end. ```php - $collection->slice(index: 1, length: 2); + $collection->slice(offset: 1, length: 2); ``` +
+ ### Comparing -These methods enable comparing collections to check for equality or to apply other comparison logic. +These methods enable comparing collections to check for equality or to verify element membership. #### Check if collection contains element -- `contains`: Checks if the Collection contains a specific element. +- `contains`: Checks if the Collection contains a specific element. Uses strict equality for scalars and loose equality + for objects. ```php $collection->contains(element: 5); @@ -252,7 +286,7 @@ These methods enable comparing collections to check for equality or to apply oth #### Compare collections for equality -- `equals`: Compares the current Collection with another collection to check if they are equal. +- `equals`: Compares the current Collection with another collection for element-wise equality. ```php $collectionA->equals(other: $collectionB); @@ -265,12 +299,20 @@ These methods enable comparing collections to check for equality or to apply oth These methods perform operations that return a single value based on the Collection's content, such as summing or combining elements. -- `reduce`: Combines all elements in the Collection into a single value using the provided aggregator function and an - initial value. - This method is helpful for accumulating results, like summing or concatenating values. +- `reduce`: Combines all elements in the Collection into a single value using the provided accumulator function and an + initial value. This method is helpful for accumulating results, like summing or concatenating values. ```php - $collection->reduce(aggregator: static fn(float $carry, float $amount): float => $carry + $amount, initial: 0.0) + $collection->reduce( + accumulator: static fn(float $carry, float $amount): float => $carry + $amount, + initial: 0.0 + ); + ``` + +- `joinToString`: Joins all elements into a string with the given separator. + + ```php + $collection->joinToString(separator: ', '); ```
@@ -282,18 +324,18 @@ 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 adding elements to another collection. + The method is helpful for performing side effects, such as logging or accumulating values. ```php - $collection->each(actions: static fn(Invoice $invoice): void => $collectionB->add(elements: new InvoiceSummary(amount: $invoice->amount, customer: $invoice->customer))); + $collection->each(actions: static fn(Amount $amount): void => $total += $amount->value); ``` #### Grouping elements -- `groupBy`: Groups the elements in the Collection based on the provided grouping criterion. +- `groupBy`: Groups the elements in the Collection based on the provided classifier. ```php - $collection->groupBy(grouping: static fn(Amount $amount): string => $amount->currency->name); + $collection->groupBy(classifier: static fn(Amount $amount): string => $amount->currency->name); ``` #### Mapping elements @@ -307,11 +349,7 @@ These methods allow the Collection's elements to be transformed or converted int #### Flattening elements -- `flatten`: Flattens a collection by removing any nested collections and returning a single collection with all - elements in a single level. - - This method recursively flattens any iterable elements, combining them into one collection, regardless of their - nesting depth. +- `flatten`: Flattens nested iterables by exactly one level. Non-iterable elements are yielded as-is. ```php $collection->flatten(); @@ -322,16 +360,16 @@ These methods allow the Collection's elements to be transformed or converted int - `toArray`: Converts the Collection into an array. ``` - PreserveKeys::DISCARD: Converts while discarding the keys. - PreserveKeys::PRESERVE: Converts while preserving the original keys. + KeyPreservation::DISCARD: Converts while discarding the keys. + KeyPreservation::PRESERVE: Converts while preserving the original keys. ``` - By default, `PreserveKeys::PRESERVE` is used. + By default, `KeyPreservation::PRESERVE` is used. ```php use TinyBlocks\Mapper\KeyPreservation; - $collection->toArray(preserveKeys: KeyPreservation::DISCARD); + $collection->toArray(keyPreservation: KeyPreservation::DISCARD); ``` #### Convert to JSON @@ -339,16 +377,16 @@ These methods allow the Collection's elements to be transformed or converted int - `toJson`: Converts the Collection into a JSON string. ``` - PreserveKeys::DISCARD: Converts while discarding the keys. - PreserveKeys::PRESERVE: Converts while preserving the original keys. + KeyPreservation::DISCARD: Converts while discarding the keys. + KeyPreservation::PRESERVE: Converts while preserving the original keys. ``` - By default, `PreserveKeys::PRESERVE` is used. + By default, `KeyPreservation::PRESERVE` is used. ```php use TinyBlocks\Mapper\KeyPreservation; - $collection->toJson(preserveKeys: KeyPreservation::DISCARD); + $collection->toJson(keyPreservation: KeyPreservation::DISCARD); ```
@@ -376,6 +414,15 @@ chained operations. However, this also means that some operations will consume the generator, and you cannot access the elements unless you recreate the `Collection`. +### 03. What is the difference between eager and lazy evaluation? + +- **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, + consuming memory only as each element is yielded. Ideal for large datasets or pipelines where not all elements need to + be materialized. +
## License diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 90bbd03..c99c9a0 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -4,8 +4,9 @@ parameters: level: 9 tmpDir: report/phpstan ignoreErrors: - - '#Unsafe usage of new static#' - - '#does not specify its types#' - - '#type specified in iterable type#' - - '#Generator expects key type#' + - '#mixed given#' + - '#iterable type#' + - '#of new static#' + - '#generic interface#' + - '#destructuring on mixed#' reportUnmatchedIgnoredErrors: false diff --git a/src/Collectible.php b/src/Collectible.php index 7258192..abce9a5 100644 --- a/src/Collectible.php +++ b/src/Collectible.php @@ -8,298 +8,243 @@ use Countable; use IteratorAggregate; use TinyBlocks\Mapper\KeyPreservation; -use Traversable; /** - * Represents a collection that can be manipulated, iterated, and counted. + * Immutable, type-safe collection contract with a fluent API. * - * Complexity notes (Big O): - * - Unless stated otherwise, complexities refer to consuming the collection **once**. - * - `n`: number of elements produced when consuming the collection once. - * - Callback cost is not included (assumed O(1) per callback invocation). + * Every mutating method returns a new instance, preserving immutability. * - * @template Key of int|string - * @template Value of mixed - * @template Element of mixed - * @extends IteratorAggregate + * Two evaluation strategies are available: + * + * - createFrom / createFromEmpty — eager evaluation, materialized immediately. + * - createLazyFrom / createLazyFromEmpty — lazy evaluation via generators, on-demand. */ interface Collectible extends Countable, IteratorAggregate { /** - * Creates a new Collectible instance from the given elements. + * Creates a collection populated with the given elements using eager evaluation. * - * Complexity: O(1) time and O(1) space to create the collection. - * Consuming the collection is O(n) time and O(1) additional space. + * Elements are materialized immediately into an array, enabling + * constant-time access by index, count, and repeated iteration. * - * @param iterable $elements The elements to initialize the Collection with. - * @return Collectible A new Collectible instance. + * @param iterable $elements The elements to populate the collection with. + * @return static A new collection containing the given elements. */ - public static function createFrom(iterable $elements): Collectible; + public static function createFrom(iterable $elements): static; /** - * Creates an empty Collectible instance. - * - * Complexity: O(1) time and O(1) space. + * Creates an empty collection using eager evaluation. * - * @return Collectible An empty Collectible instance. + * @return static An empty collection. */ - public static function createFromEmpty(): Collectible; + public static function createFromEmpty(): static; /** - * Adds one or more elements to the Collection. + * Creates a collection populated with the given elements using lazy evaluation. * - * Complexity (when consumed): O(n + k) time and O(1) additional space, - * where `k` is the number of elements passed to this method. + * Elements are processed on-demand through generators, consuming + * memory only as each element is yielded. * - * @param Element ...$elements The elements to be added to the Collection. - * @return Collectible The updated Collection. + * @param iterable $elements The elements to populate the collection with. + * @return static A new collection containing the given elements. */ - public function add(mixed ...$elements): Collectible; + public static function createLazyFrom(iterable $elements): static; /** - * Checks if the Collection contains a specific element. - * - * Complexity: best-case O(1), worst-case O(n) time (early termination), O(1) space. + * Creates an empty collection using lazy evaluation. * - * @param Element $element The element to check for. - * @return bool True if the element is found, false otherwise. + * @return static An empty collection. */ - public function contains(mixed $element): bool; + public static function createLazyFromEmpty(): static; /** - * Returns the total number of elements in the Collection. - * - * Complexity: O(n) time and O(1) additional space. + * Returns a new collection with the specified elements appended. * - * @return int The number of elements in the Collection. + * @param mixed ...$elements The elements to append. + * @return static A new collection with the additional elements. */ - public function count(): int; + public function add(mixed ...$elements): static; /** - * Executes actions on each element in the Collection without modifying it. - * - * Complexity: O(n · a) time and O(1) additional space, - * where `a` is the number of actions passed to this method. + * Merges the elements of another Collectible into the current Collection. * - * @param Closure(Element): void ...$actions The actions to perform on each element. - * @return Collectible The original Collection for chaining. + * @param Collectible $other The collection to merge with. + * @return static A new collection containing elements from both collections. */ - public function each(Closure ...$actions): Collectible; + public function merge(Collectible $other): static; /** - * Compares the Collection with another Collection for equality. + * Determines whether the collection contains the specified element. * - * Complexity: best-case O(1), worst-case O(min(n, m)) time (early termination), O(1) space, - * where `m` is the size of the other collection. + * Uses strict equality for scalars and loose equality for objects. * - * @param Collectible $other The Collection to compare with. - * @return bool True if the collections are equal, false otherwise. + * @param mixed $element The element to search for. + * @return bool True if the element exists, false otherwise. */ - public function equals(Collectible $other): bool; + public function contains(mixed $element): bool; /** - * Filters elements in the Collection based on the provided predicates. - * If no predicates are provided, all empty or falsy values (e.g., null, false, empty arrays) will be removed. - * - * Complexity (when consumed): O(n · p) time and O(1) additional space, - * where `p` is the number of predicates. + * Returns the total number of elements. * - * @param Closure(Element): bool|null ...$predicates - * @return Collectible The updated Collection. + * @return int The element count. */ - public function filter(?Closure ...$predicates): Collectible; + public function count(): int; /** - * Finds the first element that matches any of the provided predicates. - * - * Complexity: best-case O(1), worst-case O(n · q) time (early termination), O(1) space, - * where `q` is the number of predicates. + * Finds the first element that satisfies all given predicates. + * Without predicates, returns the first truthy element. * - * @param Closure(Element): bool ...$predicates The predicates to match (evaluated as a logical OR). - * @return Element|null The first matching element, or null if none is found. + * @param Closure ...$predicates + * @return mixed The first matching element or null if no match is found. */ public function findBy(Closure ...$predicates): mixed; /** - * Retrieves the first element in the Collection or a default value if not found. - * - * Complexity: best-case O(1), worst-case O(n) time (early termination), O(1) space. + * Executes side effect actions on every element without modifying the collection. * - * @param Element|null $defaultValueIfNotFound The default value returns if no element is found. - * @return Element|null The first element or the default value. + * @param Closure ...$actions Actions to perform on each element. + * @return static The same instance, enabling further chaining. */ - public function first(mixed $defaultValueIfNotFound = null): mixed; + public function each(Closure ...$actions): static; /** - * Flattens the collection by expanding iterable elements by one level (shallow flatten). + * Compares this collection with another for element-wise equality. * - * Complexity (when consumed): O(n + s) time and O(1) additional space, where `s` is the total number of elements - * inside nested iterables that are expanded. + * Two collections are equal when they have the same size and every + * pair at the same position satisfies the equality comparison. * - * @return Collectible A new Collectible instance with elements flattened by one level. + * @param Collectible $other The collection to compare against. + * @return bool True if both collections are element-wise equal. */ - public function flatten(): Collectible; + public function equals(Collectible $other): bool; /** - * Retrieves an element by its index or a default value if not found. + * Returns a new collection with the specified element removed. * - * Complexity: O(n) time and O(1) additional space. + * All occurrences of the element are removed. * - * @param int $index The index of the element to retrieve. - * @param Element|null $defaultValueIfNotFound The default value returns if no element is found. - * @return Element|null The element at the specified index or the default value. + * @param mixed $element The element to remove. + * @return static A new collection without the specified element. */ - public function getBy(int $index, mixed $defaultValueIfNotFound = null): mixed; + public function remove(mixed $element): static; /** - * Returns an iterator for traversing the Collection. - * - * Complexity: O(1) time and O(1) space to obtain the iterator. + * Returns a new collection with all elements removed that satisfy the given predicate. + * Without a predicate, all falsy values are removed. * - * @return Traversable An iterator for the Collection. + * @param Closure|null $predicate Condition to determine which elements to remove. + * @return static A new collection with the matching elements removed. */ - public function getIterator(): Traversable; + public function removeAll(?Closure $predicate = null): static; /** - * Groups the elements in the Collection based on the provided criteria. + * Retains only elements satisfying all given predicates. * - * Complexity (when consumed): O(n) time and O(n) additional space (materializes all groups). + * Without predicates, falsy values are removed. * - * @param Closure(Element): Key $grouping The function to define the group key for each element. - * @return Collectible, Element> A Collection where each value is a list of elements, - * grouped by the key returned by the closure. + * @param Closure|null ...$predicates Conditions each element must meet. + * @return static A new collection with only the matching elements. */ - public function groupBy(Closure $grouping): Collectible; + public function filter(?Closure ...$predicates): static; /** - * Determines if the Collection is empty. - * - * Complexity: best-case O(1), worst-case O(n) time (may need to advance until the first element is produced), - * O(1) space. + * Returns the first element, or a default if the collection is empty. * - * @return bool True if the Collection is empty, false otherwise. + * @param mixed $defaultValueIfNotFound Value returned when the collection is empty. + * @return mixed The first element or the default. */ - public function isEmpty(): bool; + public function first(mixed $defaultValueIfNotFound = null): mixed; /** - * Joins the elements of the Collection into a string, separated by a given separator. + * Flattens nested iterables by exactly one level. Non-iterable elements are yielded as-is. * - * Complexity: O(n + L) time and O(L) space, where `L` is the length of the resulting string. - * - * @param string $separator The string used to separate the elements. - * @return string The concatenated string of all elements in the Collection. + * @return static A new collection with elements flattened by one level. */ - public function joinToString(string $separator): string; + public function flatten(): static; /** - * Retrieves the last element in the Collection or a default value if not found. - * - * Complexity: O(n) time and O(1) space. + * Returns the element at the given zero-based index. * - * @param Element|null $defaultValueIfNotFound The default value returns if no element is found. - * @return Element|null The last element or the default value. + * @param int $index The zero-based position. + * @param mixed $defaultValueIfNotFound Value returned when the index is out of bounds. + * @return mixed The element at the index or the default. */ - public function last(mixed $defaultValueIfNotFound = null): mixed; + public function getBy(int $index, mixed $defaultValueIfNotFound = null): mixed; /** - * Applies transformations to each element in the Collection and returns a new Collection with the transformed - * elements. + * Groups elements by a key derived from each element. * - * Complexity (when consumed): O(n · t) time and O(1) additional space, - * where `t` is the number of transformations. + * The classifier receives each element and must return the group key. + * The resulting collection contains key to element-list pairs. * - * @param Closure(Element): Element ...$transformations The transformations to apply. - * @return Collectible A new Collection with the applied transformations. + * @param Closure $classifier Maps each element to its group key. + * @return static A new collection of grouped elements. */ - public function map(Closure ...$transformations): Collectible; + public function groupBy(Closure $classifier): static; /** - * Merges the elements of another Collectible into the current Collection. + * Determines whether the collection has no elements. * - * Unlike {@see add()}, which accepts individual elements via variadic parameters, - * this method accepts an entire Collectible and concatenates its elements lazily - * without materializing either collection. - * - * Complexity (when consumed): O(n + m) time and O(1) additional space, - * where `m` is the number of elements in the other Collectible. - * - * @param Collectible $other The Collectible whose elements will be appended. - * @return Collectible A new Collection containing elements from both collections. + * @return bool True if the collection is empty. */ - public function merge(Collectible $other): Collectible; + public function isEmpty(): bool; /** - * Removes a specific element from the Collection. + * Joins all elements into a string with the given separator. * - * Complexity (when consumed): O(n) time and O(1) additional space. - * - * @param Element $element The element to remove. - * @return Collectible The updated Collection. + * @param string $separator The delimiter placed between each element. + * @return string The concatenated result. */ - public function remove(mixed $element): Collectible; + public function joinToString(string $separator): string; /** - * Removes elements from the Collection based on the provided filter. - * If no filter is passed, all elements in the Collection will be removed. + * Returns the last element, or a default if the collection is empty. * - * Complexity (when consumed): O(n) time and O(1) additional space. - * - * @param Closure(Element): bool|null $filter The filter to determine which elements to remove. - * @return Collectible The updated Collection. + * @param mixed $defaultValueIfNotFound Value returned when the collection is empty. + * @return mixed The last element or the default. */ - public function removeAll(?Closure $filter = null): Collectible; + public function last(mixed $defaultValueIfNotFound = null): mixed; /** - * Reduces the elements in the Collection to a single value by applying an aggregator function. + * Applies one or more transformation functions to each element. * - * Complexity: O(n) time and O(1) additional space. + * Transformations are applied in order. Each receives the current value and key. * - * @param Closure(mixed, Element): mixed $aggregator The function that aggregates the elements. - * It receives the current accumulated value and the current element. - * @param mixed $initial The initial value to start the aggregation. - * @return mixed The final aggregated result. + * @param Closure ...$transformations Functions applied to each element. + * @return static A new collection with the transformed elements. */ - public function reduce(Closure $aggregator, mixed $initial): mixed; + public function map(Closure ...$transformations): static; /** - * Sorts the Collection based on the provided order and predicate. - * - * The order should be provided from the `Order` enum: - * - {@see Order::ASCENDING_KEY}: Sorts in ascending order by key. - * - {@see Order::DESCENDING_KEY}: Sorts in descending order by key. - * - {@see Order::ASCENDING_VALUE}: Sorts in ascending order by value. - * - {@see Order::DESCENDING_VALUE}: Sorts in descending order by value. + * Reduces the collection to a single accumulated value. * - * By default, `Order::ASCENDING_KEY` is used. + * The accumulator receives the carry and the current element. * - * Complexity (when consumed): O(n log n) time and O(n) additional space (materializes elements to sort). - * - * @param Order $order The order in which to sort the Collection. - * @param Closure(Element, Element): int|null $predicate The predicate to use for sorting. - * @return Collectible The updated Collection. + * @param Closure $accumulator Combines the carry with each element. + * @param mixed $initial The starting value for the accumulation. + * @return mixed The final accumulated result. */ - public function sort(Order $order = Order::ASCENDING_KEY, ?Closure $predicate = null): Collectible; + public function reduce(Closure $accumulator, mixed $initial): mixed; /** - * Returns a subset of the Collection starting at the specified index and containing the specified number of - * elements. - * - * If the `length` is negative, it will exclude that many elements from the end of the Collection. - * If the `length` is not provided or set to `-1`, it returns all elements starting from the index until the end. + * Returns a new collection sorted by the given order and optional comparator. * - * @param int $index The zero-based index at which to start the slice. - * @param int $length The number of elements to include in the slice. If negative, remove that many from the end. - * Default is `-1`, meaning all elements from the index onward will be included. + * Without a comparator, the spaceship operator is used. * - * Complexity (when consumed): - * - If `length === 0`: O(1) time and O(1) additional space. - * - If `length === -1`: O(n) time and O(1) additional space. - * - If `length >= 0`: O(min(n, index + length)) time and O(1) additional space (may stop early). - * - If `length < -1`: O(n) time and O(|length|) additional space (uses a buffer). + * @param Order $order The sorting direction. + * @param Closure|null $comparator Custom comparison function. + * @return static A new sorted collection. + */ + public function sort(Order $order = Order::ASCENDING_KEY, ?Closure $comparator = null): static; + + /** + * Extracts a contiguous segment of the collection. * - * @return Collectible A new Collection containing the sliced elements. + * @param int $offset Zero-based starting position. + * @param int $length Number of elements to include. Use -1 for "until the end". + * @return static A new collection with the extracted segment. */ - public function slice(int $index, int $length = -1): Collectible; + public function slice(int $offset, int $length = -1): static; /** * Converts the Collection to an array. @@ -310,10 +255,8 @@ public function slice(int $index, int $length = -1): Collectible; * * By default, `KeyPreservation::PRESERVE` is used. * - * Complexity: O(n) time and O(n) space. - * * @param KeyPreservation $keyPreservation The option to preserve or discard array keys. - * @return array The resulting array. + * @return array The resulting array. */ public function toArray(KeyPreservation $keyPreservation = KeyPreservation::PRESERVE): array; @@ -326,8 +269,6 @@ public function toArray(KeyPreservation $keyPreservation = KeyPreservation::PRES * * By default, `KeyPreservation::PRESERVE` is used. * - * Complexity: O(n + L) time and O(n + L) space, where `L` is the length of the resulting JSON. - * * @param KeyPreservation $keyPreservation The option to preserve or discard array keys. * @return string The resulting JSON string. */ diff --git a/src/Collection.php b/src/Collection.php index 6feb921..3e86c30 100644 --- a/src/Collection.php +++ b/src/Collection.php @@ -5,176 +5,182 @@ namespace TinyBlocks\Collection; use Closure; -use TinyBlocks\Collection\Internal\Iterators\LazyIterator; -use TinyBlocks\Collection\Internal\Operations\Aggregate\Reduce; -use TinyBlocks\Collection\Internal\Operations\Compare\Contains; -use TinyBlocks\Collection\Internal\Operations\Compare\Equals; -use TinyBlocks\Collection\Internal\Operations\Filter\Filter; -use TinyBlocks\Collection\Internal\Operations\Order\Sort; -use TinyBlocks\Collection\Internal\Operations\Retrieve\Find; -use TinyBlocks\Collection\Internal\Operations\Retrieve\First; -use TinyBlocks\Collection\Internal\Operations\Retrieve\Get; -use TinyBlocks\Collection\Internal\Operations\Retrieve\Last; -use TinyBlocks\Collection\Internal\Operations\Retrieve\Slice; -use TinyBlocks\Collection\Internal\Operations\Transform\Each; -use TinyBlocks\Collection\Internal\Operations\Transform\Flatten; -use TinyBlocks\Collection\Internal\Operations\Transform\GroupBy; -use TinyBlocks\Collection\Internal\Operations\Transform\JoinToString; -use TinyBlocks\Collection\Internal\Operations\Transform\Map; -use TinyBlocks\Collection\Internal\Operations\Write\Add; -use TinyBlocks\Collection\Internal\Operations\Write\Create; -use TinyBlocks\Collection\Internal\Operations\Write\Merge; -use TinyBlocks\Collection\Internal\Operations\Write\Remove; -use TinyBlocks\Collection\Internal\Operations\Write\RemoveAll; +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; +use TinyBlocks\Collection\Internal\Operations\Resolving\First; +use TinyBlocks\Collection\Internal\Operations\Resolving\Get; +use TinyBlocks\Collection\Internal\Operations\Resolving\Join; +use TinyBlocks\Collection\Internal\Operations\Resolving\Last; +use TinyBlocks\Collection\Internal\Operations\Resolving\Reduce; +use TinyBlocks\Collection\Internal\Operations\Transforming\Add; +use TinyBlocks\Collection\Internal\Operations\Transforming\Filter; +use TinyBlocks\Collection\Internal\Operations\Transforming\FlatMap; +use TinyBlocks\Collection\Internal\Operations\Transforming\GroupInto; +use TinyBlocks\Collection\Internal\Operations\Transforming\Map; +use TinyBlocks\Collection\Internal\Operations\Transforming\Merge; +use TinyBlocks\Collection\Internal\Operations\Transforming\Rearrange; +use TinyBlocks\Collection\Internal\Operations\Transforming\Remove; +use TinyBlocks\Collection\Internal\Operations\Transforming\RemoveAll; +use TinyBlocks\Collection\Internal\Operations\Transforming\Segment; +use TinyBlocks\Collection\Internal\Pipeline; use TinyBlocks\Mapper\IterableMappability; use TinyBlocks\Mapper\IterableMapper; use Traversable; /** - * Represents a collection that provides a set of utility methods for operations like adding, - * filtering, mapping, and transforming elements. Internally uses iterators to apply operations - * lazily and efficiently. + * 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; - private LazyIterator $iterator; - - private function __construct(LazyIterator $iterator) + private function __construct(private readonly Pipeline $pipeline) { - $this->iterator = $iterator; } public static function createFrom(iterable $elements): static { - return new static(iterator: LazyIterator::from(elements: $elements, operation: Create::fromEmpty())); + return new static(pipeline: EagerPipeline::from(source: $elements)); } public static function createFromEmpty(): static { - return self::createFrom(elements: []); + return static::createFrom(elements: []); + } + + public static function createLazyFrom(iterable $elements): static + { + return new static(pipeline: LazyPipeline::from(source: $elements)); + } + + public static function createLazyFromEmpty(): static + { + return static::createLazyFrom(elements: []); + } + + public function getIterator(): Traversable + { + yield from $this->pipeline->process(); } public function add(mixed ...$elements): static { - return new static(iterator: $this->iterator->add(operation: Add::from(newElements: $elements))); + return $this->pipeTo(operation: Add::these(newElements: $elements)); + } + + public function merge(Collectible $other): static + { + return $this->pipeTo(operation: Merge::with(other: $other)); } public function contains(mixed $element): bool { - return Contains::from(elements: $this->iterator)->exists(element: $element); + return Equality::exists(elements: $this, element: $element); } public function count(): int { - return iterator_count($this->iterator); + return iterator_count($this->getIterator()); + } + + public function findBy(Closure ...$predicates): mixed + { + return Find::firstMatch(elements: $this, predicates: $predicates); } public function each(Closure ...$actions): static { - Each::from(...$actions)->execute(elements: $this->iterator); + Each::execute(elements: $this, actions: $actions); + return $this; } public function equals(Collectible $other): bool { - return Equals::from(elements: $this->iterator)->compareAll(other: $other); + return Equality::compareAll(elements: $this, other: $other); } - public function filter(?Closure ...$predicates): static + public function remove(mixed $element): static { - return new static(iterator: $this->iterator->add(operation: Filter::from(...$predicates))); + return $this->pipeTo(operation: Remove::element(element: $element)); } - public function findBy(Closure ...$predicates): mixed + public function removeAll(?Closure $predicate = null): static { - return Find::from(elements: $this->iterator)->firstMatchingElement(...$predicates); + return $this->pipeTo(operation: RemoveAll::matching(predicate: $predicate)); } - public function first(mixed $defaultValueIfNotFound = null): mixed + public function filter(?Closure ...$predicates): static { - return First::from(elements: $this->iterator)->element(defaultValueIfNotFound: $defaultValueIfNotFound); + return $this->pipeTo(operation: Filter::matching(...$predicates)); } - public function flatten(): static + public function first(mixed $defaultValueIfNotFound = null): mixed { - return new static(iterator: $this->iterator->add(operation: Flatten::instance())); + return First::from(elements: $this, defaultValueIfNotFound: $defaultValueIfNotFound); } - public function getBy(int $index, mixed $defaultValueIfNotFound = null): mixed + public function flatten(): static { - return Get::from(elements: $this->iterator)->elementAtIndex( - index: $index, - defaultValueIfNotFound: $defaultValueIfNotFound - ); + return $this->pipeTo(operation: FlatMap::oneLevel()); } - public function getIterator(): Traversable + public function getBy(int $index, mixed $defaultValueIfNotFound = null): mixed { - yield from $this->iterator->getIterator(); + return Get::byIndex(elements: $this, index: $index, defaultValueIfNotFound: $defaultValueIfNotFound); } - public function groupBy(Closure $grouping): Collectible + public function groupBy(Closure $classifier): static { - return new static(iterator: $this->iterator->add(operation: GroupBy::from(grouping: $grouping))); + return $this->pipeTo(operation: GroupInto::by(classifier: $classifier)); } public function isEmpty(): bool { - return !$this->iterator->getIterator()->valid(); + return First::isAbsent(elements: $this); } public function joinToString(string $separator): string { - return JoinToString::from(elements: $this->iterator)->joinTo(separator: $separator); + return Join::elements(elements: $this, separator: $separator); } public function last(mixed $defaultValueIfNotFound = null): mixed { - return Last::from(elements: $this->iterator)->element(defaultValueIfNotFound: $defaultValueIfNotFound); + return Last::from(elements: $this, defaultValueIfNotFound: $defaultValueIfNotFound); } public function map(Closure ...$transformations): static { - return new static(iterator: $this->iterator->add(operation: Map::from(...$transformations))); - } - - public function merge(Collectible $other): static - { - return new static(iterator: $this->iterator->add(operation: Merge::from(otherElements: $other))); - } - - public function remove(mixed $element): static - { - return new static(iterator: $this->iterator->add(operation: Remove::from(element: $element))); + return $this->pipeTo(operation: Map::using(...$transformations)); } - public function removeAll(?Closure $filter = null): static + public function reduce(Closure $accumulator, mixed $initial): mixed { - return new static(iterator: $this->iterator->add(operation: RemoveAll::from(filter: $filter))); + return Reduce::from(elements: $this, accumulator: $accumulator, initial: $initial); } - public function reduce(Closure $aggregator, mixed $initial): mixed + public function sort(Order $order = Order::ASCENDING_KEY, ?Closure $comparator = null): static { - return Reduce::from(elements: $this->iterator)->execute(aggregator: $aggregator, initial: $initial); + return $this->pipeTo(operation: Rearrange::by(order: $order, comparator: $comparator)); } - public function sort(Order $order = Order::ASCENDING_KEY, ?Closure $predicate = null): static + public function slice(int $offset, int $length = -1): static { - return new static( - iterator: $this->iterator->add( - operation: Sort::from(order: $order, predicate: $predicate) - ) - ); + return $this->pipeTo(operation: Segment::from(offset: $offset, length: $length)); } - public function slice(int $index, int $length = -1): static + private function pipeTo(Operation $operation): static { - return new static( - iterator: $this->iterator->add( - operation: Slice::from(index: $index, length: $length) - ) - ); + return new static(pipeline: $this->pipeline->pipe(operation: $operation)); } } diff --git a/src/Internal/EagerPipeline.php b/src/Internal/EagerPipeline.php new file mode 100644 index 0000000..1a66c40 --- /dev/null +++ b/src/Internal/EagerPipeline.php @@ -0,0 +1,41 @@ +apply(elements: $this->elements)); + + return new EagerPipeline(elements: $elements); + } + + public function process(): Generator + { + yield from $this->elements; + } +} diff --git a/src/Internal/Iterators/LazyIterator.php b/src/Internal/Iterators/LazyIterator.php deleted file mode 100644 index bbfb922..0000000 --- a/src/Internal/Iterators/LazyIterator.php +++ /dev/null @@ -1,68 +0,0 @@ - - */ -final class LazyIterator implements IteratorAggregate -{ - /** - * @var LazyOperation[] - */ - private array $operations; - - /** - * @param iterable $elements - * @param LazyOperation $operation - */ - private function __construct(private readonly iterable $elements, LazyOperation $operation) - { - $this->operations[] = $operation; - } - - /** - * @param iterable $elements - * @param LazyOperation $operation - * @return LazyIterator - */ - public static function from(iterable $elements, LazyOperation $operation): LazyIterator - { - return new LazyIterator(elements: $elements, operation: $operation); - } - - /** - * @param LazyOperation $operation - * @return LazyIterator - */ - public function add(LazyOperation $operation): LazyIterator - { - $this->operations[] = $operation; - return $this; - } - - /** - * @return Generator - */ - public function getIterator(): Generator - { - $currentElements = $this->elements; - - foreach ($this->operations as $operation) { - $currentElements = $operation->apply(elements: $currentElements); - } - - yield from $currentElements; - } -} diff --git a/src/Internal/Iterators/LazyIteratorAggregate.php b/src/Internal/Iterators/LazyIteratorAggregate.php deleted file mode 100644 index 2eeaa90..0000000 --- a/src/Internal/Iterators/LazyIteratorAggregate.php +++ /dev/null @@ -1,43 +0,0 @@ - - */ -final readonly class LazyIteratorAggregate implements IteratorAggregate -{ - /** - * @param iterable $elements - */ - private function __construct(private iterable $elements) - { - } - - /** - * @param iterable $elements - * @return LazyIteratorAggregate - */ - public static function from(iterable $elements): LazyIteratorAggregate - { - return new LazyIteratorAggregate(elements: $elements); - } - - /** - * @return Generator - */ - public function getIterator(): Generator - { - yield from $this->elements; - } -} diff --git a/src/Internal/LazyPipeline.php b/src/Internal/LazyPipeline.php new file mode 100644 index 0000000..806b275 --- /dev/null +++ b/src/Internal/LazyPipeline.php @@ -0,0 +1,52 @@ +stages = $stages; + } + + public static function from(iterable $source): LazyPipeline + { + return new LazyPipeline(source: $source); + } + + public function pipe(Operation $operation): Pipeline + { + $stages = $this->stages; + $stages[] = $operation; + + return new LazyPipeline(source: $this->source, stages: $stages); + } + + public function process(): Generator + { + $elements = $this->source; + + foreach ($this->stages as $stage) { + $elements = $stage->apply(elements: $elements); + } + + yield from $elements; + } +} diff --git a/src/Internal/Operations/Aggregate/Reduce.php b/src/Internal/Operations/Aggregate/Reduce.php deleted file mode 100644 index 5dd4961..0000000 --- a/src/Internal/Operations/Aggregate/Reduce.php +++ /dev/null @@ -1,31 +0,0 @@ -elements as $element) { - $carry = $aggregator($carry, $element); - } - - return $carry; - } -} diff --git a/src/Internal/Operations/Compare/Contains.php b/src/Internal/Operations/Compare/Contains.php deleted file mode 100644 index 9327a8c..0000000 --- a/src/Internal/Operations/Compare/Contains.php +++ /dev/null @@ -1,32 +0,0 @@ -elements as $current) { - if ($equals->compareWith(element: $current, otherElement: $element)) { - return true; - } - } - - return false; - } -} diff --git a/src/Internal/Operations/Compare/Equals.php b/src/Internal/Operations/Compare/Equals.php deleted file mode 100644 index 60b4976..0000000 --- a/src/Internal/Operations/Compare/Equals.php +++ /dev/null @@ -1,54 +0,0 @@ -getIterator(); - $targetIterator = LazyIteratorAggregate::from(elements: $this->elements)->getIterator(); - - while ($currentIterator->valid() || $targetIterator->valid()) { - if (!$currentIterator->valid() || !$targetIterator->valid()) { - return false; - } - - if (!$this->compareWith(element: $currentIterator->current(), otherElement: $targetIterator->current())) { - return false; - } - - $currentIterator->next(); - $targetIterator->next(); - } - - return true; - } - - public function compareWith(mixed $element, mixed $otherElement): bool - { - return is_object($element) && is_object($otherElement) - ? $element == $otherElement - : $element === $otherElement; - } -} diff --git a/src/Internal/Operations/Filter/Filter.php b/src/Internal/Operations/Filter/Filter.php deleted file mode 100644 index 9dd4b8e..0000000 --- a/src/Internal/Operations/Filter/Filter.php +++ /dev/null @@ -1,50 +0,0 @@ -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 - { - return new Filter(...$predicates); - } - - public function apply(iterable $elements): Generator - { - $predicate = $this->compiledPredicate; - - foreach ($elements as $key => $value) { - if ($predicate($value, $key)) { - yield $key => $value; - } - } - } -} diff --git a/src/Internal/Operations/ImmediateOperation.php b/src/Internal/Operations/ImmediateOperation.php deleted file mode 100644 index 0b9891e..0000000 --- a/src/Internal/Operations/ImmediateOperation.php +++ /dev/null @@ -1,12 +0,0 @@ - $elements The collection of elements to apply the operation on. - * @return Generator A generator that yields the results of applying the operation. - */ - public function apply(iterable $elements): Generator; -} diff --git a/src/Internal/Operations/Operation.php b/src/Internal/Operations/Operation.php index 13b9752..3e6f0ba 100644 --- a/src/Internal/Operations/Operation.php +++ b/src/Internal/Operations/Operation.php @@ -4,9 +4,34 @@ namespace TinyBlocks\Collection\Internal\Operations; +use Generator; + /** - * Defines operations to the collection. + * Represents a single processing stage in a collection pipeline. + * + * Each operation encapsulates a discrete transformation that receives + * a stream of elements and produces a new stream. Operations are + * designed to be composed sequentially, forming a pipeline where + * the output of one stage feeds into the next. + * + * Implementations must be stateless relative to the input stream, + * meaning they should not retain references to previously seen elements + * unless the nature of the operation requires it (e.g., sorting). + * + * @template TKey of int|string + * @template TValue */ interface Operation { + /** + * Processes the given elements and yields the resulting stream. + * + * The operation consumes elements on demand from the input iterable + * and produces transformed elements through a generator, preserving + * lazy evaluation semantics across the pipeline. + * + * @param iterable $elements The input stream of elements to process. + * @return Generator A generator yielding the processed elements. + */ + public function apply(iterable $elements): Generator; } diff --git a/src/Internal/Operations/Order/Sort.php b/src/Internal/Operations/Order/Sort.php deleted file mode 100644 index d310dc9..0000000 --- a/src/Internal/Operations/Order/Sort.php +++ /dev/null @@ -1,51 +0,0 @@ - $value) { - $temporaryElements[$key] = $value; - } - } - - $predicate = is_null($this->predicate) - ? static fn(mixed $first, mixed $second): int => $first <=> $second - : $this->predicate; - - $ascendingPredicate = static fn(mixed $first, mixed $second): int => $predicate($first, $second); - $descendingPredicate = static fn(mixed $first, mixed $second): int => $predicate($second, $first); - - match ($this->order) { - Order::ASCENDING_KEY => ksort($temporaryElements), - Order::DESCENDING_KEY => krsort($temporaryElements), - Order::ASCENDING_VALUE => uasort($temporaryElements, $ascendingPredicate), - Order::DESCENDING_VALUE => uasort($temporaryElements, $descendingPredicate) - }; - - foreach ($temporaryElements as $key => $value) { - yield $key => $value; - } - } -} diff --git a/src/Internal/Operations/Resolving/Each.php b/src/Internal/Operations/Resolving/Each.php new file mode 100644 index 0000000..b364f24 --- /dev/null +++ b/src/Internal/Operations/Resolving/Each.php @@ -0,0 +1,17 @@ + $value) { + foreach ($actions as $action) { + $action($value, $key); + } + } + } +} diff --git a/src/Internal/Operations/Resolving/Equality.php b/src/Internal/Operations/Resolving/Equality.php new file mode 100644 index 0000000..30c68e0 --- /dev/null +++ b/src/Internal/Operations/Resolving/Equality.php @@ -0,0 +1,55 @@ +valid() && $iteratorB->valid()) { + if (!Equality::areSame(element: $iteratorA->current(), other: $iteratorB->current())) { + return false; + } + + $iteratorA->next(); + $iteratorB->next(); + } + + return !$iteratorA->valid() && !$iteratorB->valid(); + } + + private static function toGenerator(iterable $iterable): Generator + { + yield from $iterable; + } +} diff --git a/src/Internal/Operations/Resolving/Find.php b/src/Internal/Operations/Resolving/Find.php new file mode 100644 index 0000000..118fbde --- /dev/null +++ b/src/Internal/Operations/Resolving/Find.php @@ -0,0 +1,21 @@ + $predicate($element))) { + return $element; + } + } + + return null; + } +} diff --git a/src/Internal/Operations/Resolving/First.php b/src/Internal/Operations/Resolving/First.php new file mode 100644 index 0000000..94fc0ee --- /dev/null +++ b/src/Internal/Operations/Resolving/First.php @@ -0,0 +1,26 @@ + $value) { + if ($currentIndex === $index) { + return $value; + } + } + + return $defaultValueIfNotFound; + } +} diff --git a/src/Internal/Operations/Resolving/Join.php b/src/Internal/Operations/Resolving/Join.php new file mode 100644 index 0000000..9d27741 --- /dev/null +++ b/src/Internal/Operations/Resolving/Join.php @@ -0,0 +1,19 @@ +elements as $element) { - if (array_any($predicates, static fn(Closure $predicate): bool => $predicate($element))) { - return $element; - } - } - - return null; - } -} diff --git a/src/Internal/Operations/Retrieve/First.php b/src/Internal/Operations/Retrieve/First.php deleted file mode 100644 index 2592c12..0000000 --- a/src/Internal/Operations/Retrieve/First.php +++ /dev/null @@ -1,28 +0,0 @@ -elements as $element) { - return $element; - } - - return $defaultValueIfNotFound; - } -} diff --git a/src/Internal/Operations/Retrieve/Get.php b/src/Internal/Operations/Retrieve/Get.php deleted file mode 100644 index 49218f3..0000000 --- a/src/Internal/Operations/Retrieve/Get.php +++ /dev/null @@ -1,30 +0,0 @@ -elements as $currentIndex => $value) { - if ($currentIndex === $index) { - return $value; - } - } - - return $defaultValueIfNotFound; - } -} diff --git a/src/Internal/Operations/Retrieve/Last.php b/src/Internal/Operations/Retrieve/Last.php deleted file mode 100644 index 764a4b8..0000000 --- a/src/Internal/Operations/Retrieve/Last.php +++ /dev/null @@ -1,30 +0,0 @@ -elements as $element) { - $lastElement = $element; - } - - return $lastElement; - } -} diff --git a/src/Internal/Operations/Retrieve/Slice.php b/src/Internal/Operations/Retrieve/Slice.php deleted file mode 100644 index 2403ad1..0000000 --- a/src/Internal/Operations/Retrieve/Slice.php +++ /dev/null @@ -1,75 +0,0 @@ -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 - { - $buffer = new SplQueue(); - $skipFromEnd = abs($this->length); - $currentIndex = 0; - - foreach ($elements as $key => $value) { - if ($currentIndex++ < $this->index) { - continue; - } - - $buffer->enqueue([$key, $value]); - - if ($buffer->count() <= $skipFromEnd) { - continue; - } - - $dequeued = $buffer->dequeue(); - - if (is_array($dequeued)) { - [$yieldKey, $yieldValue] = $dequeued; - yield $yieldKey => $yieldValue; - } - } - } -} diff --git a/src/Internal/Operations/Transform/Each.php b/src/Internal/Operations/Transform/Each.php deleted file mode 100644 index 25d9bff..0000000 --- a/src/Internal/Operations/Transform/Each.php +++ /dev/null @@ -1,32 +0,0 @@ -actions = $actions; - } - - public static function from(Closure ...$actions): Each - { - return new Each(...$actions); - } - - public function execute(iterable $elements): void - { - foreach ($elements as $key => $value) { - foreach ($this->actions as $action) { - $action($value, $key); - } - } - } -} diff --git a/src/Internal/Operations/Transform/Flatten.php b/src/Internal/Operations/Transform/Flatten.php deleted file mode 100644 index a270129..0000000 --- a/src/Internal/Operations/Transform/Flatten.php +++ /dev/null @@ -1,31 +0,0 @@ -grouping)($element); - $groupedElements[$key][] = $element; - } - - foreach ($groupedElements as $key => $group) { - yield $key => $group; - } - } -} diff --git a/src/Internal/Operations/Transform/JoinToString.php b/src/Internal/Operations/Transform/JoinToString.php deleted file mode 100644 index 441aeff..0000000 --- a/src/Internal/Operations/Transform/JoinToString.php +++ /dev/null @@ -1,37 +0,0 @@ -elements as $element) { - if ($first) { - $result = $element; - $first = false; - continue; - } - - $result .= sprintf('%s%s', $separator, $element); - } - - return $result; - } -} diff --git a/src/Internal/Operations/Transforming/Add.php b/src/Internal/Operations/Transforming/Add.php new file mode 100644 index 0000000..79930bd --- /dev/null +++ b/src/Internal/Operations/Transforming/Add.php @@ -0,0 +1,31 @@ +newElements as $value) { + yield $value; + } + } +} diff --git a/src/Internal/Operations/Transforming/Filter.php b/src/Internal/Operations/Transforming/Filter.php new file mode 100644 index 0000000..debb77b --- /dev/null +++ b/src/Internal/Operations/Transforming/Filter.php @@ -0,0 +1,43 @@ +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) + ), + }; + } + + public static function matching(?Closure ...$predicates): Filter + { + return new Filter(...$predicates); + } + + public function apply(iterable $elements): Generator + { + $predicate = $this->compiledPredicate; + + foreach ($elements as $key => $value) { + if ($predicate($value, $key)) { + yield $key => $value; + } + } + } +} diff --git a/src/Internal/Operations/Transforming/FlatMap.php b/src/Internal/Operations/Transforming/FlatMap.php new file mode 100644 index 0000000..41b57b9 --- /dev/null +++ b/src/Internal/Operations/Transforming/FlatMap.php @@ -0,0 +1,31 @@ +classifier)($element); + $groups[$key][] = $element; + } + + yield from $groups; + } +} diff --git a/src/Internal/Operations/Transform/Map.php b/src/Internal/Operations/Transforming/Map.php similarity index 70% rename from src/Internal/Operations/Transform/Map.php rename to src/Internal/Operations/Transforming/Map.php index 16afe45..eab652c 100644 --- a/src/Internal/Operations/Transform/Map.php +++ b/src/Internal/Operations/Transforming/Map.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace TinyBlocks\Collection\Internal\Operations\Transform; +namespace TinyBlocks\Collection\Internal\Operations\Transforming; use Closure; use Generator; -use TinyBlocks\Collection\Internal\Operations\LazyOperation; +use TinyBlocks\Collection\Internal\Operations\Operation; -final readonly class Map implements LazyOperation +final readonly class Map implements Operation { private array $transformations; @@ -17,7 +17,7 @@ private function __construct(Closure ...$transformations) $this->transformations = $transformations; } - public static function from(Closure ...$transformations): Map + public static function using(Closure ...$transformations): Map { return new Map(...$transformations); } diff --git a/src/Internal/Operations/Transforming/Merge.php b/src/Internal/Operations/Transforming/Merge.php new file mode 100644 index 0000000..4250f79 --- /dev/null +++ b/src/Internal/Operations/Transforming/Merge.php @@ -0,0 +1,31 @@ +other as $value) { + yield $value; + } + } +} diff --git a/src/Internal/Operations/Transforming/Rearrange.php b/src/Internal/Operations/Transforming/Rearrange.php new file mode 100644 index 0000000..e36bb30 --- /dev/null +++ b/src/Internal/Operations/Transforming/Rearrange.php @@ -0,0 +1,42 @@ +comparator + ?? static fn(mixed $first, mixed $second): int => $first <=> $second; + + match ($this->order) { + Order::ASCENDING_KEY => ksort($materialized), + Order::DESCENDING_KEY => krsort($materialized), + Order::ASCENDING_VALUE => uasort($materialized, $comparator), + Order::DESCENDING_VALUE => uasort( + $materialized, + static fn(mixed $first, mixed $second): int => $comparator($second, $first) + ), + }; + + yield from $materialized; + } +} diff --git a/src/Internal/Operations/Transforming/Remove.php b/src/Internal/Operations/Transforming/Remove.php new file mode 100644 index 0000000..f225a2c --- /dev/null +++ b/src/Internal/Operations/Transforming/Remove.php @@ -0,0 +1,30 @@ + $value) { + if (!Equality::areSame(element: $this->element, other: $value)) { + yield $key => $value; + } + } + } +} diff --git a/src/Internal/Operations/Transforming/RemoveAll.php b/src/Internal/Operations/Transforming/RemoveAll.php new file mode 100644 index 0000000..fbac41e --- /dev/null +++ b/src/Internal/Operations/Transforming/RemoveAll.php @@ -0,0 +1,32 @@ + $value) { + if ($this->predicate === null || ($this->predicate)($value)) { + continue; + } + + yield $key => $value; + } + } +} diff --git a/src/Internal/Operations/Transforming/Segment.php b/src/Internal/Operations/Transforming/Segment.php new file mode 100644 index 0000000..b8c8f62 --- /dev/null +++ b/src/Internal/Operations/Transforming/Segment.php @@ -0,0 +1,69 @@ +length === 0) { + return; + } + + if ($this->length < -1) { + yield from $this->withTrailingBuffer($elements); + return; + } + + $currentIndex = 0; + $emitted = 0; + + foreach ($elements as $key => $value) { + if ($currentIndex++ < $this->offset) { + continue; + } + + yield $key => $value; + $emitted++; + + if ($this->length !== -1 && $emitted >= $this->length) { + return; + } + } + } + + private function withTrailingBuffer(iterable $elements): Generator + { + $buffer = new SplQueue(); + $skipFromEnd = abs($this->length); + $currentIndex = 0; + + foreach ($elements as $key => $value) { + if ($currentIndex++ < $this->offset) { + continue; + } + + $buffer->enqueue([$key, $value]); + + if ($buffer->count() > $skipFromEnd) { + [$yieldKey, $yieldValue] = $buffer->dequeue(); + yield $yieldKey => $yieldValue; + } + } + } +} diff --git a/src/Internal/Operations/Write/Add.php b/src/Internal/Operations/Write/Add.php deleted file mode 100644 index 806b060..0000000 --- a/src/Internal/Operations/Write/Add.php +++ /dev/null @@ -1,31 +0,0 @@ -newElements as $element) { - yield $element; - } - } -} diff --git a/src/Internal/Operations/Write/Create.php b/src/Internal/Operations/Write/Create.php deleted file mode 100644 index ef8c04d..0000000 --- a/src/Internal/Operations/Write/Create.php +++ /dev/null @@ -1,21 +0,0 @@ -otherElements as $element) { - yield $element; - } - } -} diff --git a/src/Internal/Operations/Write/Remove.php b/src/Internal/Operations/Write/Remove.php deleted file mode 100644 index 20fe7d2..0000000 --- a/src/Internal/Operations/Write/Remove.php +++ /dev/null @@ -1,32 +0,0 @@ -compareWith(element: $this->element, otherElement: $element)) { - yield $element; - } - } - } -} diff --git a/src/Internal/Operations/Write/RemoveAll.php b/src/Internal/Operations/Write/RemoveAll.php deleted file mode 100644 index 9b05289..0000000 --- a/src/Internal/Operations/Write/RemoveAll.php +++ /dev/null @@ -1,32 +0,0 @@ -filter === null || ($this->filter)($element)) { - continue; - } - - yield $element; - } - } -} diff --git a/src/Internal/Pipeline.php b/src/Internal/Pipeline.php new file mode 100644 index 0000000..dfe2ead --- /dev/null +++ b/src/Internal/Pipeline.php @@ -0,0 +1,36 @@ +getIterator(); - - /** @And calling a method that does not modify the collection */ - $count = $collection->count(); - - /** @Then the iterator should not be the same (due to lazy generation) */ - self::assertSame(3, $count); - self::assertNotSame($iterator, $collection->getIterator()); - - /** @And the collection should remain unchanged */ - self::assertSame([1, 2, 3], iterator_to_array($collection->getIterator())); - } - - public function testIteratorShouldBeRecreatedAfterModification(): void - { - /** @Given a collection with elements */ - $collection = Collection::createFrom(elements: [1, 2, 3]); - - /** @When retrieving the iterator for the first time */ - $iterator = $collection->getIterator(); - - /** @And modifying the collection */ - $collection->add(elements: 4); - - /** @Then the iterator should be recreated */ - self::assertSame(4, $collection->count()); - self::assertNotSame($iterator, $collection->getIterator()); - - /** @And the elements should be correct after modification */ - self::assertSame([1, 2, 3, 4], iterator_to_array($collection->getIterator())); - } - - public function testIteratorRemainsUnchangedWithUnrelatedOperations(): void - { - /** @Given a collection with elements */ - $collection = Collection::createFrom(elements: [1, 2, 3]); - - /** @When performing operations that do not modify the collection */ - $firstIterator = $collection->getIterator(); - - /** @Then the iterator should remain unchanged */ - self::assertFalse($collection->isEmpty()); - self::assertSame([1, 2, 3], iterator_to_array($firstIterator)); - self::assertSame([1, 2, 3], iterator_to_array($collection->getIterator())); - } -} diff --git a/tests/CollectionPerformanceTest.php b/tests/CollectionPerformanceTest.php deleted file mode 100644 index 6a2ac27..0000000 --- a/tests/CollectionPerformanceTest.php +++ /dev/null @@ -1,297 +0,0 @@ - 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: static fn(Amount $first, Amount $second): int => $first->value <=> $second->value - ); - - /** @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(); - - /** @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 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: (static function (): Generator { - foreach (range(1, 100_000) as $value) { - yield new Amount(value: $value, currency: Currency::USD); - } - })() - ); - - /** @And start measuring time and memory usage */ - $this->forceGarbageCollection(); - $startTime = hrtime(true); - $startMemory = memory_get_usage(true); - - /** - * @When performing the following chained operations: - * filtering to retain the first 50,000 elements, - * mapping to double each Amount's value, - * filtering to retain the first 45,000 elements, - * further filtering to retain the first 35,000 elements, - * mapping to double each Amount's value again, - * filtering to retain the first 30,000 elements, - * further filtering to retain the first 10,000 elements, - * 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. - */ - $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(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(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); - - /** @And force full evaluation by getting first element */ - $firstElement = $result->first(); - - /** @And end measuring time and memory usage */ - $executionTimeInSeconds = (hrtime(true) - $startTime) / 1_000_000_000; - $memoryUsageInMB = (memory_get_usage(true) - $startMemory) / 1024 / 1024; - - /** @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 */ - 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 84333fa..0453aa1 100644 --- a/tests/CollectionTest.php +++ b/tests/CollectionTest.php @@ -6,81 +6,603 @@ use PHPUnit\Framework\TestCase; use Test\TinyBlocks\Collection\Models\Amount; +use Test\TinyBlocks\Collection\Models\Carriers; +use Test\TinyBlocks\Collection\Models\CryptoCurrency; +use Test\TinyBlocks\Collection\Models\Dragon; +use Test\TinyBlocks\Collection\Models\Invoice; +use Test\TinyBlocks\Collection\Models\Invoices; +use Test\TinyBlocks\Collection\Models\InvoiceSummaries; +use Test\TinyBlocks\Collection\Models\InvoiceSummary; +use Test\TinyBlocks\Collection\Models\Order; +use Test\TinyBlocks\Collection\Models\Product; +use Test\TinyBlocks\Collection\Models\Products; +use Test\TinyBlocks\Collection\Models\Status; use TinyBlocks\Collection\Collection; -use TinyBlocks\Collection\Order; +use TinyBlocks\Collection\Order as SortOrder; use TinyBlocks\Currency\Currency; +use TinyBlocks\Mapper\KeyPreservation; final class CollectionTest extends TestCase { - public function testChainedOperationsWithObjects(): void + public function testLazyAndEagerProduceSameResultsWithIntegerPipeline(): void { - /** @Given a collection of Amount objects */ - $collection = Collection::createFrom(elements: [ + /** @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 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 both collections should produce identical arrays */ + self::assertSame($lazyResult, $eagerResult); + } + + public function testLazyAndEagerProduceSameResultsWithObjectPipeline(): void + { + /** @Given a set of Amount objects */ + $elements = [ new Amount(value: 50.00, currency: Currency::USD), new Amount(value: 100.00, currency: Currency::USD), new Amount(value: 150.00, currency: Currency::USD), new Amount(value: 250.00, currency: Currency::USD), new Amount(value: 500.00, currency: Currency::USD) + ]; + + /** @And a filter predicate for amounts greater than or equal to 100 */ + $filter = static fn(Amount $amount): bool => $amount->value >= 100; + + /** @And a map transformation that applies a 10% discount */ + $map = static fn(Amount $amount): Amount => new Amount( + value: $amount->value * 0.9, + currency: $amount->currency + ); + + /** @And a removeAll predicate for amounts greater than 300 */ + $removeAll = static fn(Amount $amount): bool => $amount->value > 300; + + /** @And a comparator that sorts by value */ + $comparator = static fn(Amount $first, Amount $second): int => $first->value <=> $second->value; + + /** @When applying the operations on a lazy collection */ + $lazy = Collection::createLazyFrom(elements: $elements) + ->filter(predicates: $filter) + ->map(transformations: $map) + ->removeAll(predicate: $removeAll) + ->sort(order: SortOrder::ASCENDING_VALUE, comparator: $comparator); + + /** @And applying the same operations on an eager collection */ + $eager = Collection::createFrom(elements: $elements) + ->filter(predicates: $filter) + ->map(transformations: $map) + ->removeAll(predicate: $removeAll) + ->sort(order: SortOrder::ASCENDING_VALUE, comparator: $comparator); + + /** @Then both should have the same count */ + self::assertSame($lazy->count(), $eager->count()); + + /** @And the same first value */ + self::assertSame($lazy->first()->value, $eager->first()->value); + + /** @And the same last value */ + self::assertSame($lazy->last()->value, $eager->last()->value); + } + + public function testConcatLazyWithEager(): void + { + /** @Given a lazy collection */ + $lazy = Collection::createLazyFrom(elements: [1, 2]); + + /** @And an eager collection */ + $eager = Collection::createFrom(elements: [3, 4]); + + /** @When concatenating the eager collection into the lazy one */ + $actual = $lazy->merge(other: $eager); + + /** @Then the resulting collection should contain all four elements */ + self::assertSame([1, 2, 3, 4], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + } + + public function testConcatEagerWithLazy(): void + { + /** @Given an eager collection */ + $eager = Collection::createFrom(elements: [1, 2]); + + /** @And a lazy collection */ + $lazy = Collection::createLazyFrom(elements: [3, 4]); + + /** @When concatenating the lazy collection into the eager one */ + $actual = $eager->merge(other: $lazy); + + /** @Then the resulting collection should contain all four elements */ + self::assertSame([1, 2, 3, 4], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + } + + public function testEqualsAcrossStrategies(): void + { + /** @Given a lazy collection with elements 1, 2, 3 */ + $lazy = Collection::createLazyFrom(elements: [1, 2, 3]); + + /** @And an eager collection with elements 1, 2, 3 */ + $eager = Collection::createFrom(elements: [1, 2, 3]); + + /** @When comparing lazy equals eager */ + $lazyEqualsEager = $lazy->equals(other: $eager); + + /** @And comparing eager equals lazy */ + $eagerEqualsLazy = $eager->equals(other: $lazy); + + /** @Then the lazy-to-eager comparison should return true */ + self::assertTrue($lazyEqualsEager); + + /** @And the eager-to-lazy comparison should return true */ + self::assertTrue($eagerEqualsLazy); + } + + public function testReduceProducesSameResultAcrossStrategies(): void + { + /** @Given a set of elements */ + $elements = [1, 2, 3, 4, 5]; + + /** @And an accumulator that sums values */ + $accumulator = static fn(int $carry, int $value): int => $carry + $value; + + /** @When reducing with a lazy collection */ + $lazySum = Collection::createLazyFrom(elements: $elements) + ->reduce(accumulator: $accumulator, initial: 0); + + /** @And reducing with an eager collection */ + $eagerSum = Collection::createFrom(elements: $elements) + ->reduce(accumulator: $accumulator, initial: 0); + + /** @Then the lazy sum should be 15 */ + self::assertSame(15, $lazySum); + + /** @And the eager sum should be 15 */ + self::assertSame(15, $eagerSum); + } + + public function testCarriersPreservesTypeAfterFilter(): void + { + /** @Given a Carriers collection with three carrier names */ + $carriers = Carriers::createFrom(elements: ['DHL', 'FedEx', 'UPS']); + + /** @When filtering carriers that start with a letter after D */ + $actual = $carriers->filter( + predicates: static fn(string $name): bool => $name[0] >= 'E' + ); + + /** @Then the result should still be an instance of Carriers */ + self::assertInstanceOf(Carriers::class, $actual); + + /** @And it should contain two carriers */ + self::assertSame(2, $actual->count()); + + /** @And the carriers should be FedEx and UPS */ + self::assertSame(['FedEx', 'UPS'], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + } + + public function testCarriersPreservesTypeAfterAdd(): void + { + /** @Given a Carriers collection with two carrier names */ + $carriers = Carriers::createFrom(elements: ['DHL', 'FedEx']); + + /** @When adding a new carrier */ + $actual = $carriers->add('Correios'); + + /** @Then the result should still be an instance of Carriers */ + self::assertInstanceOf(Carriers::class, $actual); + + /** @And it should contain three carriers */ + self::assertSame(3, $actual->count()); + } + + public function testCarriersLazyPreservesTypeAfterMap(): void + { + /** @Given a lazy Carriers collection */ + $carriers = Carriers::createLazyFrom(elements: ['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)); + } + + public function testInvoicesTotalAmount(): void + { + /** @Given a set of invoices */ + $invoices = Invoices::createFrom(elements: [ + new Invoice(id: 'INV-001', amount: 100.00, customer: 'Alice'), + new Invoice(id: 'INV-002', amount: 200.00, customer: 'Bob'), + new Invoice(id: 'INV-003', amount: 150.00, customer: 'Alice') + ]); + + /** @When calculating the total amount */ + $total = $invoices->totalAmount(); + + /** @Then the total should be 450 */ + self::assertSame(450.00, $total); + } + + public function testInvoicesFilterByCustomer(): void + { + /** @Given a set of invoices for different customers */ + $invoices = Invoices::createFrom(elements: [ + new Invoice(id: 'INV-001', amount: 100.00, customer: 'Alice'), + new Invoice(id: 'INV-002', amount: 200.00, customer: 'Bob'), + new Invoice(id: 'INV-003', amount: 150.00, customer: 'Alice') + ]); + + /** @When filtering invoices for Alice */ + $aliceInvoices = $invoices->forCustomer(customer: 'Alice'); + + /** @Then the result should still be an instance of Invoices */ + self::assertInstanceOf(Invoices::class, $aliceInvoices); + + /** @And Alice should have two invoices */ + self::assertSame(2, $aliceInvoices->count()); + + /** @And the total for Alice should be 250 */ + self::assertSame(250.00, $aliceInvoices->totalAmount()); + } + + public function testInvoiceSummariesSumByCustomer(): void + { + /** @Given a collection of invoice summaries */ + $summaries = InvoiceSummaries::createFrom(elements: [ + new InvoiceSummary(amount: 100.00, customer: 'Alice'), + new InvoiceSummary(amount: 200.00, customer: 'Bob'), + new InvoiceSummary(amount: 150.00, customer: 'Alice'), + new InvoiceSummary(amount: 300.00, customer: 'Bob') + ]); + + /** @When summing by customer Alice */ + $aliceTotal = $summaries->sumByCustomer(customer: 'Alice'); + + /** @Then Alice's total should be 250 */ + self::assertSame(250.00, $aliceTotal); + } + + public function testInvoiceSummariesSumByDifferentCustomer(): void + { + /** @Given a collection of invoice summaries */ + $summaries = InvoiceSummaries::createFrom(elements: [ + new InvoiceSummary(amount: 100.00, customer: 'Alice'), + new InvoiceSummary(amount: 200.00, customer: 'Bob'), + new InvoiceSummary(amount: 150.00, customer: 'Alice'), + new InvoiceSummary(amount: 300.00, customer: 'Bob') + ]); + + /** @When summing by customer Bob */ + $bobTotal = $summaries->sumByCustomer(customer: 'Bob'); + + /** @Then Bob's total should be 500 */ + self::assertSame(500.00, $bobTotal); + } + + public function testInvoiceSummariesSumByNonExistentCustomer(): void + { + /** @Given a collection of invoice summaries */ + $summaries = InvoiceSummaries::createFrom(elements: [ + new InvoiceSummary(amount: 100.00, customer: 'Alice') ]); - /** @When chaining multiple operations: - * filter amounts greater than or equal to 100, - * apply a 10% discount, - * remove amounts greater than 300 after the discount, - * sort amounts in ascending order, - * and use each to accumulate the total discounted value */ - $totalDiscounted = 0; - $actual = $collection - ->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: 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) - ->each(actions: function (Amount $amount) use (&$totalDiscounted) { - $totalDiscounted += $amount->value; - }); - - /** @Then the final collection should contain exactly three elements */ - self::assertCount(3, $actual); - - /** @And the total discounted value should be calculated correctly */ - self::assertSame(450.00, $totalDiscounted); - - /** @And the first Amount should be 90 after the discount */ - self::assertSame(90.00, $actual->first()->value); - - /** @And the last Amount should be 225 after the discount */ - self::assertSame(225.00, $actual->last()->value); - } - - public function testChainedOperationsWithIntegers(): void - { - /** @Given a collection of integers from 1 to 100 */ - $collection = Collection::createFrom(elements: range(1, 100)); - - /** @When filtering even numbers, - * Then mapping each number to its square, - * And sorting the squared numbers in descending order */ - $actual = $collection - ->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) */ - self::assertSame(10000, $actual->first()); - - /** @And the last element after sorting should be four (square of 2) */ - self::assertSame(4, $actual->last()); - - /** @When reducing the collection to calculate the sum of all squared even numbers */ - $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); + /** @When summing by a customer that does not exist */ + $total = $summaries->sumByCustomer(customer: 'Charlie'); + + /** @Then the total should be zero */ + self::assertSame(0.0, $total); + } + + public function testDragonsGroupByDescription(): void + { + /** @Given a collection of dragons */ + $dragons = Collection::createFrom(elements: [ + new Dragon(name: 'Smaug', description: 'fire'), + new Dragon(name: 'Viserion', description: 'ice'), + new Dragon(name: 'Drogon', description: 'fire'), + new Dragon(name: 'Rhaegal', description: 'fire'), + new Dragon(name: 'Frostfyre', description: 'ice') + ]); + + /** @When grouping by description */ + $grouped = $dragons->groupBy( + classifier: static fn(Dragon $dragon): string => $dragon->description + ); + + /** @Then the fire group should contain three dragons */ + $groups = $grouped->toArray(); + self::assertCount(3, $groups['fire']); + + /** @And the ice group should contain two dragons */ + self::assertCount(2, $groups['ice']); + } + + public function testDragonsFindByName(): void + { + /** @Given a collection of dragons */ + $dragons = Collection::createFrom(elements: [ + new Dragon(name: 'Smaug', description: 'fire'), + new Dragon(name: 'Viserion', description: 'ice'), + new Dragon(name: 'Drogon', description: 'fire') + ]); + + /** @When finding the dragon named Viserion */ + $actual = $dragons->findBy( + predicates: static fn(Dragon $dragon): bool => $dragon->name === 'Viserion' + ); + + /** @Then it should return Viserion */ + self::assertSame('Viserion', $actual->name); + + /** @And Viserion should be an ice dragon */ + self::assertSame('ice', $actual->description); + } + + public function testCryptoCurrencySortByPrice(): void + { + /** @Given a collection of crypto currencies */ + $cryptos = Collection::createFrom(elements: [ + new CryptoCurrency(name: 'Bitcoin', price: 50000.00, symbol: 'BTC'), + new CryptoCurrency(name: 'Ethereum', price: 3000.00, symbol: 'ETH'), + new CryptoCurrency(name: 'Solana', price: 150.00, symbol: 'SOL') + ]); + + /** @When sorting by price in ascending order */ + $sorted = $cryptos->sort( + order: SortOrder::ASCENDING_VALUE, + comparator: static fn( + CryptoCurrency $first, + CryptoCurrency $second + ): int => $first->price <=> $second->price + ); + + /** @Then the cheapest should be Solana */ + self::assertSame('SOL', $sorted->first()->symbol); + + /** @And the most expensive should be Bitcoin */ + self::assertSame('BTC', $sorted->last()->symbol); + } + + public function testCryptoCurrencyFilterAndMapSymbols(): void + { + /** @Given a collection of crypto currencies */ + $cryptos = Collection::createFrom(elements: [ + new CryptoCurrency(name: 'Bitcoin', price: 50000.00, symbol: 'BTC'), + new CryptoCurrency(name: 'Ethereum', price: 3000.00, symbol: 'ETH'), + new CryptoCurrency(name: 'Dogecoin', price: 0.08, symbol: 'DOGE'), + new CryptoCurrency(name: 'Solana', price: 150.00, symbol: 'SOL') + ]); + + /** @When filtering currencies above 100 and mapping to symbols */ + $symbols = $cryptos + ->filter(predicates: static fn(CryptoCurrency $crypto): bool => $crypto->price > 100) + ->map(transformations: static fn(CryptoCurrency $crypto): string => $crypto->symbol) + ->sort(order: SortOrder::ASCENDING_VALUE); + + /** @Then the symbols should be BTC, ETH, SOL in alphabetical order */ + self::assertSame(['BTC', 'ETH', 'SOL'], $symbols->toArray(keyPreservation: KeyPreservation::DISCARD)); + } + + public function testProductsWithAmountFlattenFromOrders(): void + { + /** @Given a set of orders with products */ + $orders = Collection::createFrom(elements: [ + new Order(id: 1, products: new Products(elements: [ + new Product(name: 'Keyboard', amount: new Amount(value: 75.00, currency: Currency::USD)), + new Product(name: 'Mouse', amount: new Amount(value: 25.00, currency: Currency::USD)) + ])), + new Order(id: 2, products: new Products(elements: [ + new Product(name: 'Monitor', amount: new Amount(value: 500.00, currency: Currency::USD)) + ])) + ]); + + /** @When extracting all products and flattening */ + $allProducts = $orders + ->map(transformations: static fn(Order $order): array => iterator_to_array($order->products)) + ->flatten(); + + /** @Then there should be three products */ + self::assertSame(3, $allProducts->count()); + + /** @And the total cost should be 600 */ + $total = $allProducts->reduce( + accumulator: static fn(float $carry, Product $product): float => $carry + $product->amount->value, + initial: 0.0 + ); + self::assertSame(600.00, $total); + } + + public function testStatusEnumCollection(): void + { + /** @Given a collection of Status enums */ + $statuses = Collection::createFrom(elements: [ + Status::PAID, + Status::PENDING, + Status::PAID, + Status::PAID, + Status::PENDING + ]); + + /** @When filtering only PAID statuses */ + $paid = $statuses->filter( + predicates: static fn(Status $status): bool => $status === Status::PAID + ); + + /** @Then there should be three PAID statuses */ + self::assertSame(3, $paid->count()); + } + + public function testStatusEnumGroupBy(): void + { + /** @Given a collection of Status enums */ + $statuses = Collection::createFrom(elements: [ + Status::PAID, + Status::PENDING, + Status::PAID, + Status::PAID, + Status::PENDING + ]); + + /** @When grouping by status name */ + $grouped = $statuses->groupBy( + classifier: static fn(Status $status): string => $status->name + ); + + /** @Then the PAID group should have three entries */ + $groups = $grouped->toArray(); + self::assertCount(3, $groups['PAID']); + + /** @And the PENDING group should have two entries */ + self::assertCount(2, $groups['PENDING']); + } + + public function testInvoicesLazyStrategyPreservesType(): void + { + /** @Given a lazy Invoices collection */ + $invoices = Invoices::createLazyFrom(elements: [ + new Invoice(id: 'INV-001', amount: 100.00, customer: 'Alice'), + new Invoice(id: 'INV-002', amount: 200.00, customer: 'Bob') + ]); + + /** @When sorting by amount */ + $sorted = $invoices->sort( + order: SortOrder::DESCENDING_VALUE, + comparator: static fn(Invoice $first, Invoice $second): int => $first->amount <=> $second->amount + ); + + /** @Then the result should still be an instance of Invoices */ + self::assertInstanceOf(Invoices::class, $sorted); + + /** @And the first invoice should have the highest amount */ + self::assertSame(200.00, $sorted->first()->amount); + } + + public function testInvoicesSliceAndCount(): void + { + /** @Given a set of five invoices */ + $invoices = Invoices::createFrom(elements: [ + new Invoice(id: 'INV-001', amount: 100.00, customer: 'Alice'), + new Invoice(id: 'INV-002', amount: 200.00, customer: 'Bob'), + new Invoice(id: 'INV-003', amount: 300.00, customer: 'Charlie'), + new Invoice(id: 'INV-004', amount: 400.00, customer: 'Alice'), + new Invoice(id: 'INV-005', amount: 500.00, customer: 'Bob') + ]); + + /** @When slicing from offset 1 with length 3 */ + $sliced = $invoices->slice(offset: 1, length: 3); + + /** @Then the result should still be an instance of Invoices */ + self::assertInstanceOf(Invoices::class, $sliced); + + /** @And the sliced collection should have three invoices */ + self::assertSame(3, $sliced->count()); + + /** @And the total of the sliced invoices should be 900 */ + self::assertSame(900.00, $sliced->totalAmount()); + } + + public function testInvoicesRemoveSpecificInvoice(): void + { + /** @Given a specific invoice to remove */ + $toRemove = new Invoice(id: 'INV-002', amount: 200.00, customer: 'Bob'); + + /** @And a set of invoices containing that invoice */ + $invoices = Invoices::createFrom(elements: [ + new Invoice(id: 'INV-001', amount: 100.00, customer: 'Alice'), + $toRemove, + new Invoice(id: 'INV-003', amount: 300.00, customer: 'Charlie') + ]); + + /** @When removing that invoice */ + $actual = $invoices->remove(element: $toRemove); + + /** @Then there should be two invoices remaining */ + self::assertSame(2, $actual->count()); + + /** @And the total should be 400 */ + self::assertSame(400.00, $actual->totalAmount()); + } + + public function testDragonsJoinToString(): void + { + /** @Given a collection of dragons */ + $dragons = Collection::createFrom(elements: [ + new Dragon(name: 'Smaug', description: 'fire'), + new Dragon(name: 'Viserion', description: 'ice'), + new Dragon(name: 'Drogon', description: 'fire') + ]); + + /** @When mapping to names and joining with a comma */ + $names = $dragons + ->map(transformations: static fn(Dragon $dragon): string => $dragon->name) + ->joinToString(separator: ', '); + + /** @Then the result should list all dragon names */ + self::assertSame('Smaug, Viserion, Drogon', $names); + } + + public function testInvoiceSummariesPreservesTypeAfterFilter(): void + { + /** @Given a collection of invoice summaries */ + $summaries = InvoiceSummaries::createFrom(elements: [ + new InvoiceSummary(amount: 100.00, customer: 'Alice'), + new InvoiceSummary(amount: 200.00, customer: 'Bob') + ]); + + /** @When filtering for Alice */ + $filtered = $summaries->filter( + predicates: static fn(InvoiceSummary $summary): bool => $summary->customer === 'Alice' + ); + + /** @Then the result should still be an instance of InvoiceSummaries */ + self::assertInstanceOf(InvoiceSummaries::class, $filtered); + + /** @And it should contain one summary */ + self::assertSame(1, $filtered->count()); + } + + public function testConcatCarriersCollections(): void + { + /** @Given a domestic carriers collection */ + $domestic = Carriers::createFrom(elements: ['Correios', 'Jadlog']); + + /** @And an international carriers collection */ + $international = Carriers::createFrom(elements: ['DHL', 'FedEx']); + + /** @When concatenating international into domestic */ + $all = $domestic->merge(other: $international); + + /** @Then the result should still be an instance of Carriers */ + self::assertInstanceOf(Carriers::class, $all); + + /** @And it should contain four carriers */ + self::assertSame(4, $all->count()); + + /** @And the carriers should be in the expected order */ + self::assertSame(['Correios', 'Jadlog', 'DHL', 'FedEx'], + $all->toArray(keyPreservation: KeyPreservation::DISCARD) + ); } } diff --git a/tests/EagerCollectionTest.php b/tests/EagerCollectionTest.php new file mode 100644 index 0000000..bc68183 --- /dev/null +++ b/tests/EagerCollectionTest.php @@ -0,0 +1,1016 @@ +count()); + + /** @And the array should match the original elements */ + self::assertSame([1, 2, 3], $collection->toArray()); + } + + public function testFromEmpty(): void + { + /** @When creating an eager collection without arguments */ + $collection = Collection::createFromEmpty(); + + /** @Then the collection should be empty */ + self::assertTrue($collection->isEmpty()); + + /** @And the count should be zero */ + self::assertSame(0, $collection->count()); + } + + public function testFromGenerator(): 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); + + /** @Then the collection should materialize all three elements */ + self::assertSame(3, $collection->count()); + } + + public function testAdd(): void + { + /** @Given an eager collection with three elements */ + $collection = Collection::createFrom(elements: [1, 2, 3]); + + /** @When adding two more elements */ + $actual = $collection->add(4, 5); + + /** @Then the new collection should contain five elements */ + self::assertSame(5, $actual->count()); + + /** @And the elements should be in the expected order */ + self::assertSame([1, 2, 3, 4, 5], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + + /** @And the original collection should remain unchanged */ + self::assertSame(3, $collection->count()); + } + + public function testConcat(): void + { + /** @Given a first eager collection */ + $first = Collection::createFrom(elements: [1, 2]); + + /** @And a second eager collection */ + $second = Collection::createFrom(elements: [3, 4]); + + /** @When concatenating the second into the first */ + $actual = $first->merge(other: $second); + + /** @Then the resulting collection should contain four elements */ + self::assertSame(4, $actual->count()); + + /** @And the elements should be in the expected order */ + self::assertSame([1, 2, 3, 4], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + } + + public function testContainsExistingElement(): void + { + /** @Given an eager collection with integers */ + $collection = Collection::createFrom(elements: [1, 2, 3]); + + /** @When checking for an element that exists */ + $actual = $collection->contains(element: 2); + + /** @Then it should return true */ + self::assertTrue($actual); + } + + public function testContainsMissingElement(): void + { + /** @Given an eager collection with integers */ + $collection = Collection::createFrom(elements: [1, 2, 3]); + + /** @When checking for an element that does not exist */ + $actual = $collection->contains(element: 99); + + /** @Then it should return false */ + self::assertFalse($actual); + } + + public function testContainsObject(): void + { + /** @Given an Amount object to search for */ + $target = new Amount(value: 100.00, currency: Currency::USD); + + /** @And an eager collection with Amount objects */ + $collection = Collection::createFrom(elements: [ + new Amount(value: 50.00, currency: Currency::USD), + new Amount(value: 100.00, currency: Currency::USD), + new Amount(value: 200.00, currency: Currency::USD) + ]); + + /** @When checking if the collection contains an equivalent Amount */ + $actual = $collection->contains(element: $target); + + /** @Then it should return true */ + self::assertTrue($actual); + } + + public function testContainsObjectDoesNotMatchTrueScalar(): void + { + /** @Given an eager collection containing boolean true */ + $collection = Collection::createFrom(elements: [true]); + + /** @When checking if the collection contains an object */ + $actual = $collection->contains(element: new stdClass()); + + /** @Then it should return false because object and scalar types differ */ + self::assertFalse($actual); + } + + public function testCollectionWithObjectDoesNotContainTrueScalar(): void + { + /** @Given an eager collection containing a stdClass object */ + $collection = Collection::createFrom(elements: [new stdClass()]); + + /** @When checking if the collection contains boolean true */ + $actual = $collection->contains(element: true); + + /** @Then it should return false because an object is not a scalar */ + self::assertFalse($actual); + } + + public function testCount(): void + { + /** @Given an eager collection with five elements */ + $collection = Collection::createFrom(elements: [1, 2, 3, 4, 5]); + + /** @When counting the elements */ + $actual = $collection->count(); + + /** @Then it should return 5 */ + self::assertSame(5, $actual); + } + + public function testFindFirstMatch(): void + { + /** @Given an eager collection of integers */ + $collection = Collection::createFrom(elements: [1, 2, 3, 4, 5]); + + /** @When finding the first element greater than 3 */ + $actual = $collection->findBy(predicates: static fn(int $value): bool => $value > 3); + + /** @Then it should return 4 */ + self::assertSame(4, $actual); + } + + public function testFindReturnsNullWhenNoMatch(): void + { + /** @Given an eager collection of integers */ + $collection = Collection::createFrom(elements: [1, 2, 3]); + + /** @When finding an element greater than 100 */ + $actual = $collection->findBy(predicates: static fn(int $value): bool => $value > 100); + + /** @Then it should return null */ + self::assertNull($actual); + } + + public function testEach(): void + { + /** @Given an eager collection of integers */ + $collection = Collection::createFrom(elements: [1, 2, 3]); + + /** @And a variable to accumulate a sum */ + $sum = 0; + + /** @When using each to accumulate the sum */ + $actual = $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 + { + /** @Given a first eager collection */ + $first = Collection::createFrom(elements: [1, 2, 3]); + + /** @And a second eager collection with the same elements */ + $second = Collection::createFrom(elements: [1, 2, 3]); + + /** @When comparing them for equality */ + $actual = $first->equals(other: $second); + + /** @Then they should be equal */ + self::assertTrue($actual); + } + + public function testEqualsWithDifferentCollections(): void + { + /** @Given a first eager collection */ + $first = Collection::createFrom(elements: [1, 2, 3]); + + /** @And a second eager collection with different elements */ + $second = Collection::createFrom(elements: [1, 2, 4]); + + /** @When comparing them for equality */ + $actual = $first->equals(other: $second); + + /** @Then they should not be equal */ + self::assertFalse($actual); + } + + public function testEqualsWithDifferentSizes(): void + { + /** @Given a first eager collection with three elements */ + $first = Collection::createFrom(elements: [1, 2, 3]); + + /** @And a second eager collection with two elements */ + $second = Collection::createFrom(elements: [1, 2]); + + /** @When comparing first equals second */ + $firstEqualsSecond = $first->equals(other: $second); + + /** @And comparing second equals first */ + $secondEqualsFirst = $second->equals(other: $first); + + /** @Then the first comparison should return false */ + self::assertFalse($firstEqualsSecond); + + /** @And the second comparison should return false */ + self::assertFalse($secondEqualsFirst); + } + + public function testEqualsWithDifferentSizesButSamePrefix(): void + { + /** @Given a first eager collection with four elements */ + $first = Collection::createFrom(elements: [1, 2, 3, 4]); + + /** @And a second eager collection with three elements */ + $second = Collection::createFrom(elements: [1, 2, 3]); + + /** @When comparing them for equality */ + $actual = $first->equals(other: $second); + + /** @Then they should not be equal */ + self::assertFalse($actual); + } + + public function testEqualsWithNullElementsAndDifferentSizes(): void + { + /** @Given a first eager collection with three elements */ + $first = Collection::createFrom(elements: [1, 2, 3]); + + /** @And a second eager collection with four elements ending with null */ + $second = Collection::createFrom(elements: [1, 2, 3, null]); + + /** @When comparing them for equality */ + $actual = $first->equals(other: $second); + + /** @Then they should not be equal */ + self::assertFalse($actual); + } + + public function testRemoveElement(): void + { + /** @Given an eager collection with duplicate elements */ + $collection = Collection::createFrom(elements: [1, 2, 3, 2, 4]); + + /** @When removing the value 2 */ + $actual = $collection->remove(element: 2); + + /** @Then all occurrences of 2 should be removed */ + self::assertSame([1, 3, 4], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + } + + public function testRemoveScalarFromObjectCollection(): void + { + /** @Given an eager collection with Amount objects */ + $collection = Collection::createFrom(elements: [ + new Amount(value: 50.00, currency: Currency::USD), + new Amount(value: 100.00, currency: Currency::USD) + ]); + + /** @When removing a scalar value */ + $actual = $collection->remove(element: 50.00); + + /** @Then no elements should be removed */ + self::assertSame(2, $actual->count()); + } + + public function testRemovePreservesKeys(): void + { + /** @Given an eager collection with string keys */ + $collection = Collection::createFrom(elements: ['a' => 1, 'b' => 2, 'c' => 3]); + + /** @When removing the value 2 */ + $actual = $collection->remove(element: 2); + + /** @Then the remaining keys should be preserved */ + self::assertSame(['a' => 1, 'c' => 3], $actual->toArray()); + } + + public function testRemoveAllWithPredicate(): void + { + /** @Given an eager collection of integers */ + $collection = Collection::createFrom(elements: [1, 2, 3, 4, 5]); + + /** @When removing all elements greater than 3 */ + $actual = $collection->removeAll(predicate: static fn(int $value): bool => $value > 3); + + /** @Then only elements 1, 2, 3 should remain */ + self::assertSame([1, 2, 3], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + } + + public function testRemoveAllWithoutPredicate(): void + { + /** @Given an eager collection of integers */ + $collection = Collection::createFrom(elements: [1, 2, 3]); + + /** @When removing all without a predicate */ + $actual = $collection->removeAll(); + + /** @Then the collection should be empty */ + self::assertSame(0, $actual->count()); + } + + public function testRemoveAllWithNonMatchingFirstElement(): void + { + /** @Given an eager collection where the first element does not match the predicate */ + $collection = Collection::createFrom(elements: [1, 10, 2, 20, 3]); + + /** @When removing all elements greater than 5 */ + $actual = $collection->removeAll(predicate: static fn(int $value): bool => $value > 5); + + /** @Then elements 1, 2, 3 should remain */ + self::assertSame([1, 2, 3], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + } + + public function testRemoveAllPreservesKeys(): void + { + /** @Given an eager collection with string keys */ + $collection = Collection::createFrom(elements: ['a' => 1, 'b' => 2, 'c' => 3]); + + /** @When removing elements greater than 2 */ + $actual = $collection->removeAll(predicate: static fn(int $value): bool => $value > 2); + + /** @Then the remaining keys should be preserved */ + self::assertSame(['a' => 1, 'b' => 2], $actual->toArray()); + } + + public function testFirstReturnsElement(): void + { + /** @Given an eager collection with three elements */ + $collection = Collection::createFrom(elements: [10, 20, 30]); + + /** @When retrieving the first element */ + $actual = $collection->first(); + + /** @Then it should return 10 */ + self::assertSame(10, $actual); + } + + public function testFirstReturnsDefaultWhenEmpty(): void + { + /** @Given an empty eager collection */ + $collection = Collection::createFromEmpty(); + + /** @When retrieving the first element with a default */ + $actual = $collection->first(defaultValueIfNotFound: 'fallback'); + + /** @Then it should return the default value */ + self::assertSame('fallback', $actual); + } + + public function testFirstReturnsNullWhenEmpty(): void + { + /** @Given an empty eager collection */ + $collection = Collection::createFromEmpty(); + + /** @When retrieving the first element without a default */ + $actual = $collection->first(); + + /** @Then it should return null */ + self::assertNull($actual); + } + + public function testFirstReturnsNullElementInsteadOfDefault(): void + { + /** @Given an eager collection where the first element is null */ + $collection = Collection::createFrom(elements: [null, 1, 2]); + + /** @When retrieving the first element with a default */ + $actual = $collection->first(defaultValueIfNotFound: 'fallback'); + + /** @Then it should return null, not the default */ + self::assertNull($actual); + } + + public function testFlatten(): void + { + /** @Given an eager collection with nested arrays */ + $collection = Collection::createFrom(elements: [[1, 2], [3, 4], 5]); + + /** @When flattening by one level */ + $actual = $collection->flatten(); + + /** @Then all elements should be at the top level */ + self::assertSame([1, 2, 3, 4, 5], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + } + + public function testGetByIndex(): void + { + /** @Given an eager collection with three elements */ + $collection = Collection::createFrom(elements: ['a', 'b', 'c']); + + /** @When retrieving the element at index 1 */ + $actual = $collection->getBy(index: 1); + + /** @Then it should return 'b' */ + self::assertSame('b', $actual); + } + + public function testGetByIndexReturnsDefaultWhenOutOfBounds(): void + { + /** @Given an eager collection with three elements */ + $collection = Collection::createFrom(elements: ['a', 'b', 'c']); + + /** @When retrieving an element at an index that does not exist */ + $actual = $collection->getBy(index: 99, defaultValueIfNotFound: 'missing'); + + /** @Then it should return the default value */ + self::assertSame('missing', $actual); + } + + public function testGroupBy(): void + { + /** @Given an eager collection of integers from 1 to 6 */ + $collection = Collection::createFrom(elements: [1, 2, 3, 4, 5, 6]); + + /** @When grouping by even and odd */ + $actual = $collection->groupBy( + classifier: static fn(int $value): string => $value % 2 === 0 ? 'even' : 'odd' + ); + + /** @Then the odd group should contain 1, 3, 5 */ + $groups = $actual->toArray(); + self::assertSame([1, 3, 5], $groups['odd']); + + /** @And the even group should contain 2, 4, 6 */ + self::assertSame([2, 4, 6], $groups['even']); + } + + public function testIsEmpty(): void + { + /** @Given an empty eager collection */ + $empty = Collection::createFromEmpty(); + + /** @Then the empty collection should return true */ + self::assertTrue($empty->isEmpty()); + } + + public function testIsNotEmpty(): void + { + /** @Given a non-empty eager collection */ + $nonEmpty = Collection::createFrom(elements: [1]); + + /** @Then the non-empty collection should return false */ + self::assertFalse($nonEmpty->isEmpty()); + } + + public function testJoinToString(): void + { + /** @Given an eager collection of strings */ + $collection = Collection::createFrom(elements: ['a', 'b', 'c']); + + /** @When joining with a comma separator */ + $actual = $collection->joinToString(separator: ', '); + + /** @Then the result should be "a, b, c" */ + self::assertSame('a, b, c', $actual); + } + + public function testJoinToStringWithIntegers(): void + { + /** @Given an eager collection of integers */ + $collection = Collection::createFrom(elements: [1, 2, 3]); + + /** @When joining with a comma separator */ + $actual = $collection->joinToString(separator: ', '); + + /** @Then the result should be "1, 2, 3" */ + self::assertSame('1, 2, 3', $actual); + } + + public function testJoinToStringWithSingleInteger(): void + { + /** @Given an eager collection with a single integer */ + $collection = Collection::createFrom(elements: [42]); + + /** @When joining to string */ + $actual = $collection->joinToString(separator: ', '); + + /** @Then the result should be a string */ + self::assertSame('42', $actual); + } + + public function testFilterWithPredicate(): void + { + /** @Given an eager collection of integers */ + $collection = Collection::createFrom(elements: [1, 2, 3, 4, 5]); + + /** @When keeping only elements greater than 3 */ + $actual = $collection->filter(predicates: static fn(int $value): bool => $value > 3); + + /** @Then only 4 and 5 should remain */ + self::assertSame([4, 5], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + } + + public function testFilterWithoutPredicateRemovesFalsyValues(): void + { + /** @Given an eager collection with falsy and truthy values */ + $collection = Collection::createFrom(elements: [0, '', null, false, 1, 'hello', 2]); + + /** @When filtering without a predicate */ + $actual = $collection->filter(); + + /** @Then only truthy values should remain */ + self::assertSame([1, 'hello', 2], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + } + + public function testFilterWithExplicitNull(): void + { + /** @Given an eager collection with falsy and truthy values */ + $collection = Collection::createFrom(elements: [0, '', 1, 'hello', 2]); + + /** @When filtering with an explicit null predicate */ + $actual = $collection->filter(null); + + /** @Then only truthy values should remain */ + self::assertSame([1, 'hello', 2], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + } + + public function testFilterPreservesKeys(): void + { + /** @Given an eager collection with string keys */ + $collection = Collection::createFrom(elements: ['a' => 1, 'b' => 2, 'c' => 3]); + + /** @When filtering only elements greater than 1 */ + $actual = $collection->filter(predicates: static fn(int $value): bool => $value > 1); + + /** @Then the remaining keys should be preserved */ + self::assertSame(['b' => 2, 'c' => 3], $actual->toArray()); + } + + public function testLastReturnsElement(): void + { + /** @Given an eager collection with three elements */ + $collection = Collection::createFrom(elements: [10, 20, 30]); + + /** @When retrieving the last element */ + $actual = $collection->last(); + + /** @Then it should return 30 */ + self::assertSame(30, $actual); + } + + public function testLastReturnsDefaultWhenEmpty(): void + { + /** @Given an empty eager collection */ + $collection = Collection::createFromEmpty(); + + /** @When retrieving the last element with a default */ + $actual = $collection->last(defaultValueIfNotFound: 'fallback'); + + /** @Then it should return the default value */ + self::assertSame('fallback', $actual); + } + + public function testLastReturnsNullElementInsteadOfDefault(): void + { + /** @Given an eager collection where the last element is null */ + $collection = Collection::createFrom(elements: [1, 2, null]); + + /** @When retrieving the last element with a default */ + $actual = $collection->last(defaultValueIfNotFound: 'fallback'); + + /** @Then it should return null, not the default */ + self::assertNull($actual); + } + + public function testMap(): void + { + /** @Given an eager collection of integers */ + $collection = Collection::createFrom(elements: [1, 2, 3]); + + /** @When transforming each element by multiplying by 10 */ + $actual = $collection->map(transformations: static fn(int $value): int => $value * 10); + + /** @Then each element should be multiplied */ + self::assertSame([10, 20, 30], $actual->toArray()); + } + + public function testMapPreservesKeys(): void + { + /** @Given an eager collection with string keys */ + $collection = Collection::createFrom(elements: ['a' => 1, 'b' => 2, 'c' => 3]); + + /** @When transforming each element */ + $actual = $collection->map(transformations: static fn(int $value): int => $value * 10); + + /** @Then the keys should be preserved */ + self::assertSame(['a' => 10, 'b' => 20, 'c' => 30], $actual->toArray()); + } + + public function testReduce(): void + { + /** @Given an eager collection of integers */ + $collection = Collection::createFrom(elements: [1, 2, 3, 4]); + + /** @When reducing to calculate the sum */ + $actual = $collection->reduce( + accumulator: static fn(int $carry, int $value): int => $carry + $value, + initial: 0 + ); + + /** @Then the sum should be 10 */ + self::assertSame(10, $actual); + } + + public function testSortAscending(): void + { + /** @Given an eager collection with unordered elements */ + $collection = Collection::createFrom(elements: [3, 1, 2]); + + /** @When sorting in ascending order by value */ + $actual = $collection->sort(order: Order::ASCENDING_VALUE); + + /** @Then the elements should be in ascending order */ + self::assertSame([1, 2, 3], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + } + + public function testSortDescending(): void + { + /** @Given an eager collection with ordered elements */ + $collection = Collection::createFrom(elements: [1, 2, 3]); + + /** @When sorting in descending order by value */ + $actual = $collection->sort(order: Order::DESCENDING_VALUE); + + /** @Then the elements should be in descending order */ + self::assertSame([3, 2, 1], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + } + + public function testSortAscendingKey(): void + { + /** @Given an eager collection with unordered string keys */ + $collection = Collection::createFrom(elements: ['c' => 3, 'a' => 1, 'b' => 2]); + + /** @When sorting by ascending key */ + $actual = $collection->sort(); + + /** @Then the keys should be in ascending order */ + self::assertSame(['a' => 1, 'b' => 2, 'c' => 3], $actual->toArray()); + } + + public function testSortDescendingKey(): void + { + /** @Given an eager collection with ordered string keys */ + $collection = Collection::createFrom(elements: ['a' => 1, 'b' => 2, 'c' => 3]); + + /** @When sorting by descending key */ + $actual = $collection->sort(order: Order::DESCENDING_KEY); + + /** @Then the keys should be in descending order */ + self::assertSame(['c' => 3, 'b' => 2, 'a' => 1], $actual->toArray()); + } + + public function testSortAscendingValueWithoutComparator(): void + { + /** @Given an eager collection with unordered integers */ + $collection = Collection::createFrom(elements: [3, 1, 4, 1, 5]); + + /** @When sorting ascending by value without a custom comparator */ + $actual = $collection->sort(order: Order::ASCENDING_VALUE); + + /** @Then the elements should be sorted by the default spaceship operator */ + self::assertSame([1, 1, 3, 4, 5], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + } + + public function testSortWithCustomComparator(): void + { + /** @Given an eager collection of Amount objects */ + $collection = Collection::createFrom(elements: [ + new Amount(value: 300.00, currency: Currency::USD), + new Amount(value: 100.00, currency: Currency::USD), + new Amount(value: 200.00, currency: Currency::USD) + ]); + + /** @When sorting ascending by value with a custom comparator */ + $actual = $collection->sort( + order: Order::ASCENDING_VALUE, + comparator: static fn(Amount $first, Amount $second): int => $first->value <=> $second->value + ); + + /** @Then the first element should have the lowest value */ + self::assertSame(100.00, $actual->first()->value); + + /** @And the last element should have the highest value */ + self::assertSame(300.00, $actual->last()->value); + } + + public function testSortWithCustomComparatorProducesDifferentOrderThanDefault(): void + { + /** @Given an eager collection where alphabetical and length order diverge */ + $collection = Collection::createFrom(elements: ['zz', 'a', 'bbb']); + + /** @When sorting ascending by length */ + $byLength = $collection->sort( + order: Order::ASCENDING_VALUE, + comparator: static fn(string $first, string $second): int => strlen($first) <=> strlen($second) + ); + + /** @And sorting ascending by default (alphabetical) */ + $byDefault = $collection->sort(order: Order::ASCENDING_VALUE); + + /** @Then the custom order should be by length */ + self::assertSame(['a', 'zz', 'bbb'], $byLength->toArray(keyPreservation: KeyPreservation::DISCARD)); + + /** @And the default order should be alphabetical */ + self::assertSame(['a', 'bbb', 'zz'], $byDefault->toArray(keyPreservation: KeyPreservation::DISCARD)); + } + + public function testSlice(): void + { + /** @Given an eager collection of five elements */ + $collection = Collection::createFrom(elements: [10, 20, 30, 40, 50]); + + /** @When slicing from offset 1 with length 2 */ + $actual = $collection->slice(offset: 1, length: 2); + + /** @Then the result should contain elements 20 and 30 */ + self::assertSame([20, 30], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + } + + public function testSliceUntilEnd(): void + { + /** @Given an eager collection of five elements */ + $collection = Collection::createFrom(elements: [10, 20, 30, 40, 50]); + + /** @When slicing from offset 2 without specifying length */ + $actual = $collection->slice(offset: 2); + + /** @Then the result should contain all elements from index 2 onward */ + self::assertSame([30, 40, 50], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + } + + public function testSlicePreservesKeys(): void + { + /** @Given an eager collection with string keys */ + $collection = Collection::createFrom(elements: ['a' => 10, 'b' => 20, 'c' => 30, 'd' => 40]); + + /** @When slicing from offset 1 with length 2 */ + $actual = $collection->slice(offset: 1, length: 2); + + /** @Then the keys should be preserved */ + self::assertSame(['b' => 20, 'c' => 30], $actual->toArray()); + } + + public function testSliceWithZeroLengthReturnsEmpty(): void + { + /** @Given an eager collection with five elements */ + $collection = Collection::createFrom(elements: [10, 20, 30, 40, 50]); + + /** @When slicing with length zero */ + $actual = $collection->slice(offset: 0, length: 0); + + /** @Then the result should be empty */ + self::assertTrue($actual->isEmpty()); + + /** @And the count should be zero */ + self::assertSame(0, $actual->count()); + } + + public function testSliceWithNegativeLengthExcludesTrailingElements(): void + { + /** @Given an eager collection with five elements */ + $collection = Collection::createFrom(elements: [10, 20, 30, 40, 50]); + + /** @When slicing from offset 0 with length -2 (exclude last 2) */ + $actual = $collection->slice(offset: 0, length: -2); + + /** @Then the result should contain the first three elements */ + self::assertSame([10, 20, 30], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + } + + public function testSliceWithOffsetAndNegativeLength(): void + { + /** @Given an eager collection with five elements */ + $collection = Collection::createFrom(elements: [10, 20, 30, 40, 50]); + + /** @When slicing from offset 1 with length -2 (skip first, exclude last 2) */ + $actual = $collection->slice(offset: 1, length: -2); + + /** @Then the result should contain elements 20 and 30 */ + self::assertSame([20, 30], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + } + + public function testSliceWithNegativeLengthPreservesKeys(): void + { + /** @Given an eager collection with string keys */ + $collection = Collection::createFrom(elements: ['a' => 10, 'b' => 20, 'c' => 30, 'd' => 40]); + + /** @When slicing from offset 0 with length -2 */ + $actual = $collection->slice(offset: 0, length: -2); + + /** @Then the keys should be preserved */ + self::assertSame(['a' => 10, 'b' => 20], $actual->toArray()); + } + + public function testSliceWithNegativeLengthProducesExactCount(): void + { + /** @Given an eager collection with six elements */ + $collection = Collection::createFrom(elements: [1, 2, 3, 4, 5, 6]); + + /** @When slicing from offset 0 with length -3 (exclude last 3) */ + $actual = $collection->slice(offset: 0, length: -3); + + /** @Then the collection should contain exactly 3 elements */ + self::assertCount(3, $actual); + + /** @And the elements should be 1, 2, 3 */ + self::assertSame([1, 2, 3], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + } + + public function testToArrayPreservingKeys(): void + { + /** @Given an eager collection with non-sequential keys */ + $collection = Collection::createFrom(elements: [0 => 'a', 2 => 'b', 5 => 'c']); + + /** @When converting to array preserving keys */ + $actual = $collection->toArray(); + + /** @Then the keys should be preserved */ + self::assertSame([0 => 'a', 2 => 'b', 5 => 'c'], $actual); + } + + public function testToArrayDiscardingKeys(): void + { + /** @Given an eager collection with non-sequential keys */ + $collection = Collection::createFrom(elements: [0 => 'a', 2 => 'b', 5 => 'c']); + + /** @When converting to array discarding keys */ + $actual = $collection->toArray(keyPreservation: KeyPreservation::DISCARD); + + /** @Then the keys should be re-indexed from 0 */ + self::assertSame(['a', 'b', 'c'], $actual); + } + + public function testToJson(): void + { + /** @Given an eager collection of integers */ + $collection = Collection::createFrom(elements: [1, 2, 3]); + + /** @When converting to JSON */ + $actual = $collection->toJson(); + + /** @Then the result should be a valid JSON array */ + self::assertSame('[1,2,3]', $actual); + } + + public function testToJsonDiscardingKeys(): void + { + /** @Given an eager collection with string keys */ + $collection = Collection::createFrom(elements: ['x' => 1, 'y' => 2]); + + /** @When converting to JSON discarding keys */ + $actual = $collection->toJson(keyPreservation: KeyPreservation::DISCARD); + + /** @Then the result should be a sequential JSON array */ + self::assertSame('[1,2]', $actual); + } + + public function testToJsonPreservesKeysByDefault(): void + { + /** @Given an eager collection with string keys */ + $collection = Collection::createFrom(elements: ['x' => 1, 'y' => 2]); + + /** @When converting to JSON without arguments */ + $actual = $collection->toJson(); + + /** @Then the result should preserve keys as a JSON object */ + self::assertSame('{"x":1,"y":2}', $actual); + } + + public function testImmutability(): void + { + /** @Given an eager collection with three elements */ + $original = Collection::createFrom(elements: [1, 2, 3]); + + /** @When adding a new element */ + $modified = $original->add(4); + + /** @Then the original collection should remain unchanged */ + self::assertSame(3, $original->count()); + + /** @And the new collection should have four elements */ + self::assertSame(4, $modified->count()); + } + + public function testChainedOperationsWithObjects(): void + { + /** @Given an eager collection of Amount objects */ + $collection = Collection::createFrom(elements: [ + new Amount(value: 50.00, currency: Currency::USD), + new Amount(value: 100.00, currency: Currency::USD), + new Amount(value: 150.00, currency: Currency::USD), + new Amount(value: 250.00, currency: Currency::USD), + new Amount(value: 500.00, currency: Currency::USD) + ]); + + /** @And a variable to accumulate the total discounted value */ + $totalDiscounted = 0.0; + + /** @When chaining filter, map, removeAll, sort and each */ + $actual = $collection + ->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(predicate: static fn(Amount $amount): bool => $amount->value > 300) + ->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; + }); + + /** @Then the final collection should contain exactly three elements */ + self::assertCount(3, $actual); + + /** @And the total discounted value should be 450 */ + self::assertSame(450.00, $totalDiscounted); + + /** @And the first Amount should be 90 after the discount */ + self::assertSame(90.00, $actual->first()->value); + + /** @And the last Amount should be 225 after the discount */ + self::assertSame(225.00, $actual->last()->value); + } + + public function testChainedOperationsWithIntegers(): void + { + /** @Given an eager collection of integers from 1 to 100 */ + $collection = Collection::createFrom(elements: range(1, 100)); + + /** @When keeping even numbers, squaring them, and sorting in descending order */ + $actual = $collection + ->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 should be 10000 (square of 100) */ + self::assertSame(10000, $actual->first()); + + /** @And the last element should be 4 (square of 2) */ + self::assertSame(4, $actual->last()); + + /** @When reducing to calculate the sum of all squared even numbers */ + $sum = $actual->reduce( + accumulator: static fn(int $carry, int $value): int => $carry + $value, + initial: 0 + ); + + /** @Then the sum should be 171700 */ + self::assertSame(171700, $sum); + } +} diff --git a/tests/Internal/Iterators/LazyIteratorTest.php b/tests/Internal/Iterators/LazyIteratorTest.php deleted file mode 100644 index 15530ce..0000000 --- a/tests/Internal/Iterators/LazyIteratorTest.php +++ /dev/null @@ -1,34 +0,0 @@ - $value) { - yield $key => $value * 2; - } - } - }; - - /** @When creating a LazyIterator from the elements and operation */ - $iterator = LazyIterator::from(elements: $elements, operation: $operation); - - /** @Then the yielded elements should include the operation effect */ - self::assertSame([2, 4, 6], iterator_to_array($iterator)); - } -} diff --git a/tests/Internal/Operations/Aggregate/CollectionReduceOperationTest.php b/tests/Internal/Operations/Aggregate/CollectionReduceOperationTest.php deleted file mode 100644 index f4c1cbe..0000000 --- a/tests/Internal/Operations/Aggregate/CollectionReduceOperationTest.php +++ /dev/null @@ -1,126 +0,0 @@ -sumByCustomer(customer: 'Customer A'); - - /** @Then the total amount for 'Customer A' should be 250.5 */ - self::assertSame(250.5, $actual); - } - - public function testReduceSumOfNumbers(): void - { - /** @Given a collection of numbers */ - $numbers = InvoiceSummaries::createFrom(elements: [1, 2, 3, 4, 5]); - - /** @When reducing the collection to a sum */ - $actual = $numbers->reduce( - aggregator: static fn(int $carry, int $number): int => $carry + $number, - initial: 0 - ); - - /** @Then the sum should be correct */ - self::assertSame(15, $actual); - } - - public function testReduceProductOfNumbers(): void - { - /** @Given a collection of numbers */ - $numbers = InvoiceSummaries::createFrom(elements: [1, 2, 3, 4]); - - /** @When reducing the collection to a product */ - $actual = $numbers->reduce( - aggregator: static fn(int $carry, int $number): int => $carry * $number, - initial: 1 - ); - - /** @Then the product should be correct */ - self::assertSame(24, $actual); - } - - public function testReduceWhenNoMatchFound(): void - { - /** @Given a collection of invoice summaries */ - $summaries = InvoiceSummaries::createFrom(elements: [ - new InvoiceSummary(amount: 100.0, customer: 'Customer A'), - new InvoiceSummary(amount: 150.5, customer: 'Customer A'), - new InvoiceSummary(amount: 200.75, customer: 'Customer B') - ]); - - /** @When reducing the collection for a customer with no match */ - $actual = $summaries - ->filter(predicates: 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 */ - self::assertSame(0.0, $actual); - } - - public function testReduceWithMixedDataTypes(): void - { - /** @Given a collection with mixed data types */ - $mixedData = InvoiceSummaries::createFrom(elements: [1, 'string', 3.14, true]); - - /** @When reducing the collection by concatenating values as strings */ - $actual = $mixedData->reduce( - aggregator: static fn(string $carry, mixed $value): string => $carry . $value, - initial: '' - ); - - /** @Then the concatenated string should be correct */ - self::assertSame('1string3.141', $actual); - } - - public function testReduceSumForEmptyCollection(): void - { - /** @Given an empty collection of invoice summaries */ - $summaries = InvoiceSummaries::createFrom(elements: []); - - /** @When reducing an empty collection */ - $actual = $summaries->reduce( - aggregator: static fn(float $carry, InvoiceSummary $summary): float => $carry + $summary->amount, - initial: 0.0 - ); - - /** @Then the total amount should be zero */ - self::assertSame(0.0, $actual); - } - - public function testReduceWithDifferentInitialValue(): void - { - /** @Given a collection of invoice summaries */ - $summaries = InvoiceSummaries::createFrom(elements: [ - new InvoiceSummary(amount: 100.0, customer: 'Customer A'), - new InvoiceSummary(amount: 150.5, customer: 'Customer A'), - new InvoiceSummary(amount: 200.75, customer: 'Customer B') - ]); - - /** @When summing the amounts for customer 'Customer A' with an initial value of 50 */ - $actual = $summaries - ->filter(predicates: 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 */ - self::assertSame(300.5, $actual); - } -} diff --git a/tests/Internal/Operations/Compare/CollectionContainsOperationTest.php b/tests/Internal/Operations/Compare/CollectionContainsOperationTest.php deleted file mode 100644 index 38cfd97..0000000 --- a/tests/Internal/Operations/Compare/CollectionContainsOperationTest.php +++ /dev/null @@ -1,87 +0,0 @@ -contains(element: $element); - - /** @Then the collection should contain the element */ - self::assertTrue($actual); - } - - #[DataProvider('doesNotContainElementDataProvider')] - public function testDoesNotContainElement(iterable $elements, mixed $element): void - { - /** @Given a collection */ - $collection = Collection::createFrom(elements: $elements); - - /** @When checking if the element is contained in the collection */ - $actual = $collection->contains(element: $element); - - /** @Then the collection should not contain the element */ - self::assertFalse($actual); - } - - public static function containsElementDataProvider(): iterable - { - yield 'Collection contains null' => [ - 'elements' => [1, null, 3], - 'element' => null - ]; - - yield 'Collection contains element' => [ - 'elements' => [ - new CryptoCurrency(name: 'Bitcoin', price: 60000.0, symbol: 'BTC'), - new CryptoCurrency(name: 'Ethereum', price: 40000.0, symbol: 'ETH') - ], - 'element' => new CryptoCurrency(name: 'Bitcoin', price: 60000.0, symbol: 'BTC') - ]; - - yield 'Collection contains scalar value' => [ - 'elements' => [1, 'key' => 'value', 3.5], - 'element' => 'value' - ]; - } - - public static function doesNotContainElementDataProvider(): iterable - { - yield 'Empty collection' => [ - 'elements' => [], - 'element' => 1 - ]; - - yield 'Collection does not contain object' => [ - 'elements' => [new stdClass()], - 'element' => new CryptoCurrency(name: 'Bitcoin', price: 60000.0, symbol: 'BTC') - ]; - - yield 'Collection does not contain element' => [ - 'elements' => [ - new CryptoCurrency(name: 'Bitcoin', price: 60000.0, symbol: 'BTC'), - new CryptoCurrency(name: 'Ethereum', price: 40000.0, symbol: 'ETH') - ], - 'element' => new CryptoCurrency(name: 'Ripple', price: 1.0, symbol: 'XRP') - ]; - - yield 'Collection does not contain scalar value' => [ - 'elements' => [1, 'key' => 'value', 3.5], - 'element' => 42 - ]; - } -} diff --git a/tests/Internal/Operations/Compare/CollectionEqualsOperationTest.php b/tests/Internal/Operations/Compare/CollectionEqualsOperationTest.php deleted file mode 100644 index 4627b26..0000000 --- a/tests/Internal/Operations/Compare/CollectionEqualsOperationTest.php +++ /dev/null @@ -1,103 +0,0 @@ -equals(other: $collectionB); - - /** @Then the collections should be equal */ - self::assertTrue($actual); - } - - #[DataProvider('collectionsNotEqualDataProvider')] - public function testCollectionsAreNotEqual(iterable $elementsA, iterable $elementsB): void - { - /** @Given two collections */ - $collectionA = Collection::createFrom(elements: $elementsA); - $collectionB = Collection::createFrom(elements: $elementsB); - - /** @When comparing the collections */ - $actual = $collectionA->equals(other: $collectionB); - - /** @Then the collections should not be equal */ - self::assertFalse($actual); - } - - public static function collectionsEqualDataProvider(): iterable - { - yield 'Empty collections are equal' => [ - 'elementsA' => [], - 'elementsB' => [] - ]; - - yield 'Collections are equal' => [ - 'elementsA' => [ - new CryptoCurrency(name: 'Bitcoin', price: 60000.0, symbol: 'BTC'), - new CryptoCurrency(name: 'Ethereum', price: 40000.0, symbol: 'ETH') - ], - 'elementsB' => [ - new CryptoCurrency(name: 'Bitcoin', price: 60000.0, symbol: 'BTC'), - new CryptoCurrency(name: 'Ethereum', price: 40000.0, symbol: 'ETH') - ] - ]; - - yield 'Collections with mixed keys and values' => [ - 'elementsA' => [1, 'key' => 'value', 3.5], - 'elementsB' => [1, 'key' => 'value', 3.5] - ]; - } - - public static function collectionsNotEqualDataProvider(): iterable - { - yield 'Collections are not equal' => [ - 'elementsA' => [ - new CryptoCurrency(name: 'Bitcoin', price: 60000.0, symbol: 'BTC') - ], - 'elementsB' => [ - new CryptoCurrency(name: 'Ethereum', price: 40000.0, symbol: 'ETH') - ] - ]; - - yield 'Collections with different sizes' => [ - 'elementsA' => [], - 'elementsB' => [1] - ]; - - yield 'Scalar and non-scalar comparison' => [ - 'elementsA' => [1], - 'elementsB' => [new stdClass()] - ]; - - yield 'Collections with different null handling' => [ - 'elementsA' => [null], - 'elementsB' => [] - ]; - - yield 'Collections with different sizes and null' => [ - 'elementsA' => [1, 2], - 'elementsB' => [1, 2, null] - ]; - - yield 'Same elements in different order are not equal' => [ - 'elementsA' => [1, 2, 3], - 'elementsB' => [3, 2, 1] - ]; - } -} diff --git a/tests/Internal/Operations/Filter/CollectionFilterOperationTest.php b/tests/Internal/Operations/Filter/CollectionFilterOperationTest.php deleted file mode 100644 index 3eec601..0000000 --- a/tests/Internal/Operations/Filter/CollectionFilterOperationTest.php +++ /dev/null @@ -1,142 +0,0 @@ -filter( - static fn(int $value): bool => $value > 2, - static fn(int $value): bool => $value % 2 === 0, - static fn(int $value): bool => $value < 6 - ); - - /** @Then the filtered collection should discard the keys and return the expected values */ - self::assertSame([4], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); - } - - public function testFilterFailsIfPredicatesAreNotReindex(): void - { - /** @Given a collection with elements */ - $collection = Collection::createFrom(elements: [10, 20, 30]); - - /** @When filtering with a null predicate in the first position. */ - $actual = $collection->filter( - null, - static fn(int $value): bool => $value > 15 - ); - - /** @Then the filter should still work correctly. */ - $elements = iterator_to_array($actual); - - self::assertCount(2, $elements); - self::assertContains(20, $elements); - self::assertContains(30, $elements); - } - - public function testFilterIgnoresNullPredicatesAndReindexesWeights(): void - { - /** @Given a collection with elements */ - $collection = Collection::createFrom(elements: [1, 2, 3, 4, 5]); - - /** @When filtering using null values interspersed with valid predicates. */ - $actual = $collection->filter( - null, - static fn(int $value): bool => $value > 2, - null, - static fn(int $value): bool => $value < 5 - ); - - /** @Then it should only apply the valid predicates (result should be 3 and 4) */ - self::assertSame([3, 4], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); - } - - #[DataProvider('elementsDataProvider')] - public function testFilterAppliesDefaultArrayFilter(iterable $elements, iterable $expected): void - { - /** @Given a collection with elements */ - $collection = Collection::createFrom(elements: $elements); - - /** @When filtering the collection without any predicates (using default truthy filter) */ - $actual = $collection->filter(); - - /** @Then the filtered collection should contain only truthy elements */ - self::assertSame(array_values((array)$expected), $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); - } - - #[DataProvider('elementsDataProviderWithKeys')] - public function testFilterPreservesKeys(iterable $elements, iterable $expected): void - { - /** @Given a collection with elements and keys */ - $collection = Collection::createFrom(elements: $elements); - - /** @When filtering the collection without any predicates (using default truthy filter) */ - $actual = $collection->filter(); - - /** @Then the filtered collection should preserve keys for truthy elements */ - self::assertSame($expected, $actual->toArray()); - } - - public static function elementsDataProvider(): iterable - { - $bitcoin = new CryptoCurrency(name: 'Bitcoin', price: (float)rand(60000, 999999), symbol: 'BTC'); - - yield 'Empty array' => [ - 'elements' => [], - 'expected' => [] - ]; - - yield 'Array with boolean values' => [ - 'elements' => [false, true, false, true], - 'expected' => [true, true] - ]; - - yield 'Array with null and numbers' => [ - 'elements' => [null, 1, 2, 0], - 'expected' => [1, 2] - ]; - - yield 'Array with only falsy values' => [ - 'elements' => [0, '', null, false], - 'expected' => [] - ]; - - yield 'Array with objects and truthy values' => [ - 'elements' => [$bitcoin, 1, 'valid string'], - 'expected' => [$bitcoin->toArray(keyPreservation: KeyPreservation::DISCARD), 1, 'valid string'] - ]; - } - - public static function elementsDataProviderWithKeys(): iterable - { - $bitcoin = new CryptoCurrency(name: 'Bitcoin', price: 60000.0, symbol: 'BTC'); - - yield 'Array with keys preserved' => [ - 'elements' => ['a' => false, 'b' => true, 'c' => false, 'd' => true], - 'expected' => ['b' => true, 'd' => true] - ]; - - yield 'Mixed elements with keys' => [ - 'elements' => ['first' => null, 'second' => 1, 'third' => $bitcoin], - 'expected' => ['second' => 1, 'third' => $bitcoin->toArray()] - ]; - } -} diff --git a/tests/Internal/Operations/Order/CollectionSortOperationTest.php b/tests/Internal/Operations/Order/CollectionSortOperationTest.php deleted file mode 100644 index 9b32126..0000000 --- a/tests/Internal/Operations/Order/CollectionSortOperationTest.php +++ /dev/null @@ -1,200 +0,0 @@ -sort(); - - /** @Then the collection should remain empty */ - self::assertSame([], $actual->toArray()); - } - - #[DataProvider('ascendingKeySortDataProvider')] - public function testSortAscendingByKey(iterable $elements, iterable $expected): void - { - /** @Given a collection with unordered elements */ - $collection = Collection::createFrom(elements: $elements); - - /** @When sorting the collection in ascending order by key */ - $actual = $collection->sort(); - - /** @Then the collection should be sorted by key in ascending order */ - self::assertSame($expected, $actual->toArray()); - } - - #[DataProvider('descendingKeySortDataProvider')] - public function testSortDescendingByKey(iterable $elements, iterable $expected): void - { - /** @Given a collection with unordered elements */ - $collection = Collection::createFrom(elements: $elements); - - /** @When sorting the collection in descending order by key */ - $actual = $collection->sort(order: Order::DESCENDING_KEY); - - /** @Then the collection should be sorted by key in descending order */ - self::assertSame($expected, $actual->toArray()); - } - - #[DataProvider('ascendingValueSortDataProvider')] - public function testSortAscendingByValue(iterable $elements, iterable $expected): void - { - /** @Given a collection with unordered elements */ - $collection = Collection::createFrom(elements: $elements); - - /** @When sorting the collection in ascending order by value */ - $actual = $collection->sort(order: Order::ASCENDING_VALUE); - - /** @Then the collection should be sorted by value in ascending order */ - self::assertSame($expected, $actual->toArray()); - } - - #[DataProvider('descendingValueSortDataProvider')] - public function testSortDescendingByValue(iterable $elements, iterable $expected): void - { - /** @Given a collection with unordered elements */ - $collection = Collection::createFrom(elements: $elements); - - /** @When sorting the collection in descending order by value */ - $actual = $collection->sort(order: Order::DESCENDING_VALUE); - - /** @Then the collection should be sorted by value in descending order */ - self::assertSame($expected, $actual->toArray()); - } - - public function testSortDescendingByValueWithPredicate(): void - { - /** @Given a collection with unordered Amount objects */ - $collection = Collection::createFrom(elements: [ - new Amount(value: 100.50, currency: Currency::USD), - new Amount(value: 150.75, currency: Currency::EUR), - new Amount(value: 200.00, currency: Currency::USD) - ]); - - /** @When sorting the collection in descending order by value with a custom predicate */ - $actual = $collection->sort( - order: Order::DESCENDING_VALUE, - predicate: static fn(Amount $first, Amount $second): int => $first->value <=> $second->value - ); - - /** @Then the collection should be sorted by value in descending order */ - self::assertSame([ - 2 => ['value' => 200.00, 'currency' => Currency::USD->name], - 1 => ['value' => 150.75, 'currency' => Currency::EUR->name], - 0 => ['value' => 100.50, 'currency' => Currency::USD->name] - ], $actual->toArray()); - } - - public static function ascendingKeySortDataProvider(): iterable - { - yield 'Floats ascending by key' => [ - 'elements' => ['3.1' => 'a', '1.1' => 'b', '4.1' => 'c', '5.1' => 'd', '2.1' => 'e'], - 'expected' => ['1.1' => 'b', '2.1' => 'e', '3.1' => 'a', '4.1' => 'c', '5.1' => 'd'] - ]; - - yield 'Strings ascending by key' => [ - 'elements' => ['c' => 'apple', 'a' => 'banana', 'b' => 'cherry'], - 'expected' => ['a' => 'banana', 'b' => 'cherry', 'c' => 'apple'] - ]; - - yield 'Integers ascending by key' => [ - 'elements' => [3 => 'a', 1 => 'b', 4 => 'c', 5 => 'd', 2 => 'e'], - 'expected' => [1 => 'b', 2 => 'e', 3 => 'a', 4 => 'c', 5 => 'd'] - ]; - } - - public static function ascendingValueSortDataProvider(): iterable - { - yield 'Floats ascending by value' => [ - 'elements' => [3 => 5.5, 1 => 1.1, 4 => 3.3, 5 => 4.4, 2 => 2.2], - 'expected' => [1 => 1.1, 2 => 2.2, 4 => 3.3, 5 => 4.4, 3 => 5.5] - ]; - - yield 'Objects ascending by value' => [ - 'elements' => [ - new Amount(value: 200.00, currency: Currency::USD), - new Amount(value: 150.75, currency: Currency::EUR), - new Amount(value: 100.50, currency: Currency::USD) - ], - 'expected' => [ - 2 => ['value' => 100.50, 'currency' => Currency::USD->name], - 1 => ['value' => 150.75, 'currency' => Currency::EUR->name], - 0 => ['value' => 200.00, 'currency' => Currency::USD->name] - ] - ]; - - yield 'Strings ascending by value' => [ - 'elements' => [3 => 'c', 1 => 'a', 4 => 'd', 5 => 'b', 2 => 'e'], - 'expected' => [1 => 'a', 5 => 'b', 3 => 'c', 4 => 'd', 2 => 'e'] - ]; - - yield 'Integers ascending by value' => [ - 'elements' => [3 => 5, 1 => 1, 4 => 3, 5 => 4, 2 => 2], - 'expected' => [1 => 1, 2 => 2, 4 => 3, 5 => 4, 3 => 5] - ]; - } - - public static function descendingKeySortDataProvider(): iterable - { - yield 'Floats descending by key' => [ - 'elements' => ['3.1' => 'a', '1.1' => 'b', '4.1' => 'c', '5.1' => 'd', '2.1' => 'e'], - 'expected' => ['5.1' => 'd', '4.1' => 'c', '3.1' => 'a', '2.1' => 'e', '1.1' => 'b'] - ]; - - yield 'Strings descending by key' => [ - 'elements' => ['c' => 'apple', 'a' => 'banana', 'b' => 'cherry'], - 'expected' => ['c' => 'apple', 'b' => 'cherry', 'a' => 'banana'] - ]; - - yield 'Integers descending by key' => [ - 'elements' => [3 => 'a', 1 => 'b', 4 => 'c', 5 => 'd', 2 => 'e'], - 'expected' => [5 => 'd', 4 => 'c', 3 => 'a', 2 => 'e', 1 => 'b'] - ]; - } - - public static function descendingValueSortDataProvider(): iterable - { - yield 'Floats descending by value' => [ - 'elements' => [3 => 5.5, 1 => 1.1, 4 => 3.3, 5 => 4.4, 2 => 2.2], - 'expected' => [3 => 5.5, 5 => 4.4, 4 => 3.3, 2 => 2.2, 1 => 1.1] - ]; - - yield 'Objects descending by value' => [ - 'elements' => [ - new Amount(value: 100.50, currency: Currency::USD), - new Amount(value: 150.75, currency: Currency::EUR), - new Amount(value: 200.00, currency: Currency::USD) - ], - 'expected' => [ - 2 => ['value' => 200.00, 'currency' => Currency::USD->name], - 1 => ['value' => 150.75, 'currency' => Currency::EUR->name], - 0 => ['value' => 100.50, 'currency' => Currency::USD->name] - ] - ]; - - yield 'Strings descending by value' => [ - 'elements' => [3 => 'c', 1 => 'a', 4 => 'd', 5 => 'b', 2 => 'e'], - 'expected' => [2 => 'e', 4 => 'd', 3 => 'c', 5 => 'b', 1 => 'a'] - ]; - - yield 'Integers descending by value' => [ - 'elements' => [3 => 5, 1 => 1, 4 => 3, 5 => 4, 2 => 2], - 'expected' => [3 => 5, 5 => 4, 4 => 3, 2 => 2, 1 => 1] - ]; - } -} diff --git a/tests/Internal/Operations/Retrieve/CollectionFindOperationTest.php b/tests/Internal/Operations/Retrieve/CollectionFindOperationTest.php deleted file mode 100644 index bb0b6ba..0000000 --- a/tests/Internal/Operations/Retrieve/CollectionFindOperationTest.php +++ /dev/null @@ -1,98 +0,0 @@ -findBy(predicates: static fn(mixed $element): bool => true); - - /** @Then the result should be null */ - self::assertNull($actual); - } - - public function testFindByReturnsNullWhenNoMatch(): void - { - /** @Given a collection with elements */ - $collection = Collection::createFrom(elements: [ - new CryptoCurrency(name: 'Bitcoin', price: 60000.0, symbol: 'BTC'), - new CryptoCurrency(name: 'Ethereum', price: 40000.0, symbol: 'ETH'), - new CryptoCurrency(name: 'Binance Coin', price: 1500.0, symbol: 'BNB') - ]); - - /** @When attempting to find an element that doesn't match any condition */ - $actual = $collection->findBy(predicates: static fn(CryptoCurrency $element): bool => $element->symbol === 'XRP' - ); - - /** @Then the result should be null */ - self::assertNull($actual); - } - - public function testFindByWithMultiplePredicates(): void - { - /** @Given a collection with elements */ - $elements = [ - new CryptoCurrency(name: 'Bitcoin', price: 60000.0, symbol: 'BTC'), - new CryptoCurrency(name: 'Ethereum', price: 40000.0, symbol: 'ETH'), - new CryptoCurrency(name: 'Binance Coin', price: 1500.0, symbol: 'BNB') - ]; - $collection = Collection::createFrom(elements: $elements); - - /** @When attempting to find the first element matching multiple predicates */ - $actual = $collection->findBy( - 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 */ - self::assertSame($elements[2], $actual); - } - - public function testFindByReturnsFirstMatchingElement(): void - { - /** @Given a collection with elements */ - $elements = [ - new CryptoCurrency(name: 'Bitcoin', price: 60000.0, symbol: 'BTC'), - new CryptoCurrency(name: 'Ethereum', price: 40000.0, symbol: 'ETH'), - new CryptoCurrency(name: 'Binance Coin', price: 1500.0, symbol: 'BNB') - ]; - $collection = Collection::createFrom(elements: $elements); - - /** @When attempting to find the first matching element */ - $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); - } - - public function testFindByWithMultiplePredicatesReturnsNullWhenNoMatch(): void - { - /** @Given a collection with elements */ - $collection = Collection::createFrom(elements: [ - new CryptoCurrency(name: 'Bitcoin', price: 60000.0, symbol: 'BTC'), - new CryptoCurrency(name: 'Ethereum', price: 40000.0, symbol: 'ETH'), - new CryptoCurrency(name: 'Binance Coin', price: 1500.0, symbol: 'BNB') - ]); - - /** @When attempting to find an element matching multiple predicates that do not match */ - $actual = $collection->findBy( - static fn(CryptoCurrency $element): bool => $element->symbol === 'XRP', - static fn(CryptoCurrency $element): bool => $element->price < 1000.0 - ); - - /** @Then the result should be null */ - self::assertNull($actual); - } -} diff --git a/tests/Internal/Operations/Retrieve/CollectionFirstOperationTest.php b/tests/Internal/Operations/Retrieve/CollectionFirstOperationTest.php deleted file mode 100644 index 12d0157..0000000 --- a/tests/Internal/Operations/Retrieve/CollectionFirstOperationTest.php +++ /dev/null @@ -1,65 +0,0 @@ -first(); - - /** @Then the result should be the first CryptoCurrency object */ - self::assertSame($elements[0], $actual); - } - - public function testFirstReturnsNullWhenFirstElementIsNull(): void - { - /** @Given a collection whose first element is null */ - $collection = Collection::createFrom(elements: [null, 'value']); - - /** @When retrieving the first element with a default value */ - $actual = $collection->first(defaultValueIfNotFound: 'default'); - - /** @Then the first element should be null */ - self::assertNull($actual); - } - - public function testFirstReturnsDefaultValueWhenCollectionIsEmpty(): void - { - /** @Given an empty collection */ - $collection = Collection::createFromEmpty(); - - /** @When attempting to get the first element */ - $actual = $collection->first(defaultValueIfNotFound: 'default'); - - /** @Then the result should be the default value */ - self::assertSame('default', $actual); - } - - public function testFirstReturnsNullWhenCollectionIsEmptyWithoutDefaultValue(): void - { - /** @Given an empty collection */ - $collection = Collection::createFromEmpty(); - - /** @When retrieving the first element without a default value */ - $actual = $collection->first(); - - /** @Then the first element should be null */ - self::assertNull($actual); - } -} diff --git a/tests/Internal/Operations/Retrieve/CollectionGetOperationTest.php b/tests/Internal/Operations/Retrieve/CollectionGetOperationTest.php deleted file mode 100644 index 152b4bd..0000000 --- a/tests/Internal/Operations/Retrieve/CollectionGetOperationTest.php +++ /dev/null @@ -1,178 +0,0 @@ -getBy(index: 0); - - /** @Then the result should be the expected element */ - self::assertSame($elements[0], $actual); - } - - public function testGetByIndexReturnsElementAtValidIndex(): void - { - /** @Given a collection with elements */ - $elements = [ - new CryptoCurrency(name: 'Bitcoin', price: 60000.0, symbol: 'BTC'), - new CryptoCurrency(name: 'Ethereum', price: 40000.0, symbol: 'ETH'), - new CryptoCurrency(name: 'Binance Coin', price: 1500.0, symbol: 'BNB') - ]; - $collection = Collection::createFrom(elements: $elements); - - /** @When attempting to get an element at a valid index */ - $actual = $collection->getBy(index: 1); - - /** @Then the result should be the expected element */ - self::assertSame($elements[1], $actual); - } - - public function testGetByIndexReturnsNullForNegativeIndex(): void - { - /** @Given a collection with elements */ - $collection = Collection::createFrom(elements: [ - new CryptoCurrency(name: 'Bitcoin', price: 60000.0, symbol: 'BTC'), - new CryptoCurrency(name: 'Ethereum', price: 40000.0, symbol: 'ETH'), - new CryptoCurrency(name: 'Binance Coin', price: 1500.0, symbol: 'BNB') - ]); - - /** @When attempting to get an element at a negative index */ - $actual = $collection->getBy(index: -1); - - /** @Then the result should be null */ - self::assertNull($actual); - } - - public function testGetByIndexReturnsDefaultValueWhenIndexIsNegative(): void - { - /** @Given a collection with elements */ - $collection = Collection::createFrom(elements: [ - new CryptoCurrency(name: 'Bitcoin', price: 60000.0, symbol: 'BTC'), - new CryptoCurrency(name: 'Ethereum', price: 40000.0, symbol: 'ETH'), - new CryptoCurrency(name: 'Binance Coin', price: 1500.0, symbol: 'BNB') - ]); - - /** @And a default value when the element is not found */ - $defaultValue = 'not-found'; - - /** @When attempting to get an element at a negative index */ - $actual = $collection->getBy(index: -1, defaultValueIfNotFound: $defaultValue); - - /** @Then the default value should be returned */ - self::assertSame($defaultValue, $actual); - } - - public function testGetByIndexReturnsNullForEmptyCollection(): void - { - /** @Given an empty collection */ - $collection = Collection::createFrom(elements: []); - - /** @When attempting to get an element at any index */ - $actual = $collection->getBy(index: 0); - - /** @Then the result should be null */ - self::assertNull($actual); - } - - public function testGetByIndexReturnsNullForOutOfRangeIndex(): void - { - /** @Given a collection with elements */ - $collection = Collection::createFrom(elements: [ - new CryptoCurrency(name: 'Bitcoin', price: (float)rand(60000, 999999), symbol: 'BTC'), - new CryptoCurrency(name: 'Ethereum', price: (float)rand(10000, 60000), symbol: 'ETH'), - new CryptoCurrency(name: 'Binance Coin', price: (float)rand(1000, 2000), symbol: 'BNB') - ]); - - /** @When attempting to get an element at an out-of-range index */ - $actual = $collection->getBy(index: 10); - - /** @Then the result should be null */ - self::assertNull($actual); - } - - public function testGetByIndexReturnsDefaultValueWhenElementExistsAndDefaultIsEqual(): void - { - /** @Given a collection with elements */ - $elements = [ - new CryptoCurrency(name: 'Bitcoin', price: 60000.0, symbol: 'BTC'), - new CryptoCurrency(name: 'Ethereum', price: 40000.0, symbol: 'ETH') - ]; - $collection = Collection::createFrom(elements: $elements); - - /** @When attempting to get an element at a valid index with a default return value equal to the element */ - $expected = $elements[1]; - $actual = $collection->getBy(index: 1, defaultValueIfNotFound: $expected); - - /** @Then the result should be the element at the given index */ - self::assertSame($expected, $actual); - } - - #[DataProvider('getByIndexDataProvider')] - public function testGetByIndexReturnsExpectedValue(int $index, iterable $elements, mixed $expected): void - { - /** @Given a collection with elements */ - $collection = Collection::createFrom(elements: $elements); - - /** @When attempting to get an element at the specified index */ - $actual = $collection->getBy(index: $index); - - /** @Then the result should be the expected value */ - self::assertEquals($expected, $actual); - } - - public static function getByIndexDataProvider(): iterable - { - yield 'Valid index' => [ - 'index' => 1, - 'elements' => [ - new CryptoCurrency(name: 'Bitcoin', price: 60000.0, symbol: 'BTC'), - new CryptoCurrency(name: 'Ethereum', price: 40000.0, symbol: 'ETH'), - new CryptoCurrency(name: 'Binance Coin', price: 1500.0, symbol: 'BNB') - ], - 'expected' => new CryptoCurrency(name: 'Ethereum', price: 40000.0, symbol: 'ETH') - ]; - - yield 'Negative index' => [ - 'index' => -1, - 'elements' => [ - new CryptoCurrency(name: 'Bitcoin', price: 60000.0, symbol: 'BTC'), - new CryptoCurrency(name: 'Ethereum', price: 40000.0, symbol: 'ETH'), - new CryptoCurrency(name: 'Binance Coin', price: 1500.0, symbol: 'BNB') - ], - 'expected' => null - ]; - - yield 'Empty elements' => [ - 'index' => 0, - 'elements' => [], - 'expected' => null - ]; - - yield 'Out of range index' => [ - 'index' => 5, - 'elements' => [ - new CryptoCurrency(name: 'Bitcoin', price: 60000.0, symbol: 'BTC'), - new CryptoCurrency(name: 'Ethereum', price: 40000.0, symbol: 'ETH') - ], - 'expected' => null - ]; - } -} diff --git a/tests/Internal/Operations/Retrieve/CollectionLastOperationTest.php b/tests/Internal/Operations/Retrieve/CollectionLastOperationTest.php deleted file mode 100644 index c1d0fb8..0000000 --- a/tests/Internal/Operations/Retrieve/CollectionLastOperationTest.php +++ /dev/null @@ -1,95 +0,0 @@ -last(); - - /** @Then the result should be the last CryptoCurrency object */ - self::assertSame($elements[2], $actual); - } - - public function testLastReturnsNullWhenLastElementIsNull(): void - { - /** @Given a collection whose last element is null */ - $collection = Collection::createFrom(elements: ['value', null]); - - /** @When retrieving the last element with a default value */ - $actual = $collection->last(defaultValueIfNotFound: 'default'); - - /** @Then the last element should be null */ - self::assertNull($actual); - } - - public function testLastReturnsDefaultValueWhenCollectionIsEmpty(): void - { - /** @Given an empty collection */ - $collection = Collection::createFromEmpty(); - - /** @When attempting to get the last element */ - $actual = $collection->last(defaultValueIfNotFound: 'default'); - - /** @Then the result should be the default value */ - self::assertSame('default', $actual); - } - - public function testLastReturnsLastElementFromSplDoublyLinkedList(): void - { - /** @Given a collection created from a SplDoublyLinkedList */ - $elements = new SplDoublyLinkedList(); - $elements->push('first'); - $elements->push('second'); - $elements->push('third'); - $collection = Collection::createFrom(elements: $elements); - - /** @When retrieving the last element */ - $actual = $collection->last(); - - /** @Then the result should be the last value */ - self::assertSame('third', $actual); - } - - public function testLastReturnsLastElementFromArrayAccessCountableIterable(): void - { - /** @Given a collection created from an ArrayIterator */ - $collection = Collection::createFrom(elements: new ArrayIterator(['alpha', 'beta', 'gamma'])); - - /** @When retrieving the last element */ - $actual = $collection->last(); - - /** @Then the result should be the last value */ - self::assertSame('gamma', $actual); - } - - public function testLastReturnsNullWhenCollectionIsEmptyWithoutDefaultValue(): void - { - /** @Given an empty collection */ - $collection = Collection::createFromEmpty(); - - /** @When retrieving the last element without a default value */ - $actual = $collection->last(); - - /** @Then the last element should be null */ - self::assertNull($actual); - } -} diff --git a/tests/Internal/Operations/Retrieve/CollectionSliceOperationTest.php b/tests/Internal/Operations/Retrieve/CollectionSliceOperationTest.php deleted file mode 100644 index 12f9a43..0000000 --- a/tests/Internal/Operations/Retrieve/CollectionSliceOperationTest.php +++ /dev/null @@ -1,124 +0,0 @@ -slice(index: 1, length: 2); - - /** @Then the result should contain the sliced elements */ - self::assertSame([ - 1 => $elements[1]->toArray(), - 2 => $elements[2]->toArray() - ], $actual->toArray()); - } - - public function testSliceReturnsEmptyWhenIndexExceedsCollectionSize(): void - { - /** @Given a collection of CryptoCurrency objects */ - $elements = [ - new CryptoCurrency(name: 'Bitcoin', price: 60000.0, symbol: 'BTC'), - new CryptoCurrency(name: 'Ethereum', price: 40000.0, symbol: 'ETH'), - new CryptoCurrency(name: 'Binance Coin', price: 1500.0, symbol: 'BNB') - ]; - $collection = Collection::createFrom(elements: $elements); - - /** @When slicing the collection with an index that exceeds the collection size */ - $actual = $collection->slice(index: 5, length: 2); - - /** @Then the result should be an empty array */ - self::assertEmpty($actual->toArray()); - } - - public function testSliceWithZeroLengthReturnsEmpty(): void - { - /** @Given a collection of CryptoCurrency objects */ - $elements = [ - new CryptoCurrency(name: 'Bitcoin', price: 60000.0, symbol: 'BTC'), - new CryptoCurrency(name: 'Ethereum', price: 40000.0, symbol: 'ETH'), - new CryptoCurrency(name: 'Binance Coin', price: 1500.0, symbol: 'BNB') - ]; - $collection = Collection::createFrom(elements: $elements); - - /** @When slicing with length 0 */ - $actual = $collection->slice(index: 1, length: 0); - - /** @Then the result should be an empty array */ - self::assertEmpty($actual->toArray()); - } - - public function testSliceWithLengthOne(): void - { - /** @Given a collection of CryptoCurrency objects */ - $elements = [ - new CryptoCurrency(name: 'Bitcoin', price: 60000.0, symbol: 'BTC'), - new CryptoCurrency(name: 'Ethereum', price: 40000.0, symbol: 'ETH'), - new CryptoCurrency(name: 'Binance Coin', price: 1500.0, symbol: 'BNB') - ]; - $collection = Collection::createFrom(elements: $elements); - - /** @When slicing with length 1 */ - $actual = $collection->slice(index: 1, length: 1); - - /** @Then the result should contain only one element */ - self::assertSame([1 => $elements[1]->toArray()], $actual->toArray()); - } - - public function testSliceWithNegativeTwoLength(): void - { - /** @Given a collection of CryptoCurrency objects */ - $elements = [ - new CryptoCurrency(name: 'Bitcoin', price: 60000.0, symbol: 'BTC'), - new CryptoCurrency(name: 'Ethereum', price: 40000.0, symbol: 'ETH'), - new CryptoCurrency(name: 'Binance Coin', price: 1500.0, symbol: 'BNB'), - new CryptoCurrency(name: 'Cardano', price: 2.0, symbol: 'ADA') - ]; - $collection = Collection::createFrom(elements: $elements); - - /** @When slicing with length -2 */ - $actual = $collection->slice(index: 1, length: -2); - - /** @Then the result should contain only the first element after the index */ - self::assertSame([1 => $elements[1]->toArray()], $actual->toArray()); - } - - public function testSliceWithoutPassingLength(): void - { - /** @Given a collection of CryptoCurrency objects */ - $elements = [ - new CryptoCurrency(name: 'Bitcoin', price: 60000.0, symbol: 'BTC'), - new CryptoCurrency(name: 'Ethereum', price: 40000.0, symbol: 'ETH'), - new CryptoCurrency(name: 'Binance Coin', price: 1500.0, symbol: 'BNB'), - new CryptoCurrency(name: 'Cardano', price: 2.0, symbol: 'ADA') - ]; - $collection = Collection::createFrom(elements: $elements); - - /** @When slicing without a passing length (defaults to -1) */ - $actual = $collection->slice(index: 1); - - /** @Then the result should contain all elements starting from the index */ - self::assertSame([ - 1 => $elements[1]->toArray(), - 2 => $elements[2]->toArray(), - 3 => $elements[3]->toArray() - ], $actual->toArray()); - } -} diff --git a/tests/Internal/Operations/Transform/CollectionEachOperationTest.php b/tests/Internal/Operations/Transform/CollectionEachOperationTest.php deleted file mode 100644 index 1d511e9..0000000 --- a/tests/Internal/Operations/Transform/CollectionEachOperationTest.php +++ /dev/null @@ -1,85 +0,0 @@ -each(function (Invoice $invoice) use ($summaries): void { - $summaries->add(new InvoiceSummary(amount: $invoice->amount, customer: $invoice->customer)); - }); - - /** @Then the invoice summaries should contain the mapped data */ - self::assertCount(3, $summaries); - - $expected = [ - ['amount' => 100.0, 'customer' => 'Customer A'], - ['amount' => 150.5, 'customer' => 'Customer B'], - ['amount' => 200.75, 'customer' => 'Customer C'] - ]; - - self::assertEquals($expected, $summaries->toArray()); - } - - public function testEachWithMultipleActions(): void - { - /** @Given a collection with elements */ - $collection = Collection::createFrom(elements: [1, 2, 3]); - - /** @When executing multiple actions */ - $result = []; - $collection->each( - function (int $value) use (&$result): void { - $result[] = $value + 1; - }, - function (int $value) use (&$result): void { - $result[] = $value * 2; - } - ); - - /** @Then the result should reflect the actions */ - self::assertSame([2, 2, 3, 4, 4, 6], $result); - } - - public function testPreserveKeysWithMultipleActions(): void - { - /** @Given a collection with associative array elements */ - $collection = Collection::createFrom(elements: ['a' => 1, 'b' => 2, 'c' => 3]); - - /** @When executing actions and collecting the results */ - $result = []; - $collection->each( - function (int $value, string $key) use (&$result): void { - $result[$key] = $value * 2; - }, - function (int $value, string $key) use (&$result): void { - $result[$key] += 1; - } - ); - - /** @Then the result should contain the modified elements with preserved keys */ - self::assertSame(['a' => 3, 'b' => 5, 'c' => 7], $result); - } -} diff --git a/tests/Internal/Operations/Transform/CollectionFlattenOperationTest.php b/tests/Internal/Operations/Transform/CollectionFlattenOperationTest.php deleted file mode 100644 index ef3f34c..0000000 --- a/tests/Internal/Operations/Transform/CollectionFlattenOperationTest.php +++ /dev/null @@ -1,68 +0,0 @@ -flatten(); - - /** @Then the collection should remain empty */ - self::assertEmpty($actual->toArray()); - } - - public function testFlattenNestedCollections(): void - { - /** @Given a collection of nested collections */ - $collection = Collection::createFrom(elements: [ - Collection::createFrom(elements: [1, 2]), - Collection::createFrom(elements: [3, 4]), - Collection::createFrom(elements: [5, 6]) - ]); - - /** @When flattening the collection */ - $actual = $collection->flatten(); - - /** @Then the collection should contain all elements in a single collection */ - self::assertSame([1, 2, 3, 4, 5, 6], $actual->toArray()); - } - - public function testFlattenNonNestedCollection(): void - { - /** @Given a collection without any nested elements */ - $collection = Collection::createFrom(elements: [1, 2, 3]); - - /** @When flattening the collection */ - $actual = $collection->flatten(); - - /** @Then the collection should remain unchanged */ - self::assertSame([1, 2, 3], $actual->toArray()); - } - - public function testFlattenWithMixedNestedElements(): void - { - /** @Given a collection with mixed nested and non-nested elements */ - $collection = Collection::createFrom(elements: [ - 1, - Collection::createFrom(elements: [2, 3]), - 4, - Collection::createFrom(elements: [5, 6]) - ]); - - /** @When flattening the collection */ - $actual = $collection->flatten(); - - /** @Then the collection should contain all elements in a single collection */ - self::assertSame([1, 2, 3, 4, 5, 6], $actual->toArray()); - } -} diff --git a/tests/Internal/Operations/Transform/CollectionGroupByOperationTest.php b/tests/Internal/Operations/Transform/CollectionGroupByOperationTest.php deleted file mode 100644 index cce3fdf..0000000 --- a/tests/Internal/Operations/Transform/CollectionGroupByOperationTest.php +++ /dev/null @@ -1,98 +0,0 @@ -groupBy(grouping: static fn(Amount $amount): string => $amount->currency->name); - - /** @Then the collection should be grouped by the currency */ - $expected = Collection::createFrom(elements: [ - 'BRL' => [ - new Amount(value: 55.1, currency: Currency::BRL), - new Amount(value: 23.3, currency: Currency::BRL) - ], - 'USD' => [ - new Amount(value: 100.5, currency: Currency::USD), - new Amount(value: 200.0, currency: Currency::USD) - ] - ]); - - self::assertEquals($expected->toArray(), $actual->toArray()); - } - - public function testGroupBySimpleKey(): void - { - /** @Given a collection of elements with a type property */ - $collection = Collection::createFrom(elements: [ - ['type' => 'fruit', 'name' => 'apple'], - ['type' => 'fruit', 'name' => 'banana'], - ['type' => 'vegetable', 'name' => 'carrot'], - ['type' => 'vegetable', 'name' => 'broccoli'] - ]); - - /** @When grouping by the 'type' key */ - $actual = $collection->groupBy(grouping: static fn(array $item): string => $item['type']); - - /** @Then the collection should be grouped by the type property */ - $expected = Collection::createFrom(elements: [ - 'fruit' => Collection::createFrom(elements: [ - ['type' => 'fruit', 'name' => 'apple'], - ['type' => 'fruit', 'name' => 'banana'] - ]), - 'vegetable' => Collection::createFrom(elements: [ - ['type' => 'vegetable', 'name' => 'carrot'], - ['type' => 'vegetable', 'name' => 'broccoli'] - ]) - ]); - - self::assertSame($expected->toArray(), $actual->toArray()); - } - - public function testGroupByNumericKey(): void - { - /** @Given a collection of numbers */ - $collection = Collection::createFrom(elements: [1, 2, 3, 4, 5, 6]); - - /** @When grouping by even and odd numbers */ - $actual = $collection->groupBy(grouping: static fn(int $item): string => $item % 2 === 0 ? 'even' : 'odd'); - - /** @Then the collection should be grouped into even and odd */ - $expected = Collection::createFrom(elements: [ - 'odd' => Collection::createFrom(elements: [1, 3, 5]), - 'even' => Collection::createFrom(elements: [2, 4, 6]) - ]); - - self::assertSame($expected->toArray(), $actual->toArray()); - } - - public function testGroupByEmptyCollection(): void - { - /** @Given an empty collection */ - $collection = Collection::createFromEmpty(); - - /** @When applying groupBy on the empty collection */ - $actual = $collection->groupBy(grouping: static fn(array $item): array => $item); - - /** @Then the collection should remain empty */ - self::assertEmpty($actual->toArray()); - } -} diff --git a/tests/Internal/Operations/Transform/CollectionJoinToStringTest.php b/tests/Internal/Operations/Transform/CollectionJoinToStringTest.php deleted file mode 100644 index 47946f7..0000000 --- a/tests/Internal/Operations/Transform/CollectionJoinToStringTest.php +++ /dev/null @@ -1,95 +0,0 @@ -joinToString(separator: ','); - - /** @Then the result should be a string with elements joined by the separator */ - self::assertSame('1,2,3', $actual); - } - - public function testJoinToStringWithEmptyCollection(): void - { - /** @Given an empty collection */ - $collection = Collection::createFromEmpty(); - - /** @When joining the empty collection elements with a separator */ - $actual = $collection->joinToString(separator: ','); - - /** @Then the result should be an empty string */ - self::assertSame('', $actual); - } - - public function testJoinToStringWithCustomSeparator(): void - { - /** @Given a collection of strings */ - $collection = Collection::createFrom(elements: ['apple', 'banana', 'cherry']); - - /** @When joining the collection elements with a dash separator */ - $actual = $collection->joinToString(separator: '-'); - - /** @Then the result should be a string with elements joined by the custom separator */ - self::assertSame('apple-banana-cherry', $actual); - } - - public function testJoinToStringWithSingleElement(): void - { - /** @Given a collection with a single element */ - $collection = Collection::createFrom(elements: ['onlyOne']); - - /** @When joining the collection elements with a space separator */ - $actual = $collection->joinToString(separator: ','); - - /** @Then the result should be the single element as a string */ - self::assertSame('onlyOne', $actual); - } - - public function testJoinToStringWithNonStringElements(): void - { - /** @Given a collection of mixed elements */ - $collection = Collection::createFrom(elements: [1, 2.5, true, null]); - - /** @When joining the collection elements with a comma separator */ - $actual = $collection->joinToString(separator: ','); - - /** @Then the result should be a string representation of the elements joined by the separator */ - self::assertSame('1,2.5,1,', $actual); - } - - public function testJoinToStringWithSpaceSeparator(): void - { - /** @Given a collection of integers */ - $collection = Collection::createFrom(elements: [1, 2, 3]); - - /** @When joining the collection elements with a space separator */ - $actual = $collection->joinToString(separator: ' '); - - /** @Then the result should be a string with elements joined by the space separator */ - self::assertSame('1 2 3', $actual); - } - - public function testJoinToStringWithStringElementsAndSpaceSeparator(): void - { - /** @Given a collection of strings */ - $collection = Collection::createFrom(elements: ['apple', 'banana', 'cherry']); - - /** @When joining the collection elements with a space separator */ - $actual = $collection->joinToString(separator: ' '); - - /** @Then the result should be a string with elements joined by the space separator */ - self::assertSame('apple banana cherry', $actual); - } -} diff --git a/tests/Internal/Operations/Transform/CollectionMapOperationTest.php b/tests/Internal/Operations/Transform/CollectionMapOperationTest.php deleted file mode 100644 index d2de4b6..0000000 --- a/tests/Internal/Operations/Transform/CollectionMapOperationTest.php +++ /dev/null @@ -1,94 +0,0 @@ - 'Smaug', 'description' => 'Fire-breathing dragon'], - ['name' => 'Shenron', 'description' => 'Eternal dragon'], - ['name' => 'Toothless', 'description' => 'Night Fury dragon'] - ]); - - /** @When mapped to convert arrays into objects */ - $actual = $collection->map(static function (iterable $data): Dragon { - return new Dragon(name: $data['name'], description: $data['description']); - }); - - /** @Then the collection should contain transformed objects */ - $expected = Collection::createFrom(elements: [ - new Dragon(name: 'Smaug', description: 'Fire-breathing dragon'), - new Dragon(name: 'Shenron', description: 'Eternal dragon'), - new Dragon(name: 'Toothless', description: 'Night Fury dragon') - ]); - - self::assertSame($expected->toArray(), $actual->toArray()); - } - - public function testMapPreservesKeys(): void - { - /** @Given a collection with associative array elements */ - $collection = Collection::createFrom(elements: ['a' => 1, 'b' => 2, 'c' => 3]); - - /** @When mapping the collection with a transformation */ - $actual = $collection->map(transformations: static fn(int $value): int => $value * 2); - - /** @Then the mapped collection should preserve the keys */ - self::assertSame(['a' => 2, 'b' => 4, 'c' => 6], $actual->toArray()); - } - - public function testMapEmptyCollection(): void - { - /** @Given an empty collection */ - $collection = Collection::createFromEmpty(); - - /** @When mapping the empty collection with transformations */ - $actual = $collection->map( - static fn(int $value): int => $value * 2, - static fn(int $value): int => $value + 1 - ); - - /** @Then the collection should remain empty */ - self::assertEmpty($actual->toArray()); - } - - public function testMapWithSingleTransformation(): void - { - /** @Given a collection with elements */ - $collection = Collection::createFrom(elements: [1, 2, 3]); - - /** @When mapping the collection with a transformation */ - $actual = $collection->map(transformations: static fn(int $value): int => $value * 2); - - /** @Then the collection should contain transformed elements */ - self::assertSame([2, 4, 6], $actual->toArray()); - } - - public function testMapWithMultipleTransformations(): void - { - /** @Given a collection with elements */ - $collection = Collection::createFrom(elements: [1, 2, 3]); - - /** - * @When mapping the collection with two transformations, - * the first transformation squares each value, - * and the second transformation increments each value by 1. - */ - $actual = $collection->map( - static fn(int $value): int => $value * $value, - static fn(int $value): int => $value + 1 - ); - - /** @Then the collection should contain elements transformed by the transformations */ - self::assertSame([2, 5, 10], $actual->toArray()); - } -} diff --git a/tests/Internal/Operations/Transform/CollectionMapToArrayOperationTest.php b/tests/Internal/Operations/Transform/CollectionMapToArrayOperationTest.php deleted file mode 100644 index a472a19..0000000 --- a/tests/Internal/Operations/Transform/CollectionMapToArrayOperationTest.php +++ /dev/null @@ -1,94 +0,0 @@ -toArray(); - - /** @Then the array representation should match the expected format */ - self::assertSame($expected, $actual); - self::assertSame(count((array)$expected), $collection->count()); - } - - public static function elementsDataProvider(): iterable - { - $amountInBrl = new Amount(value: 55.1, currency: Currency::BRL); - $amountInUsd = new Amount(value: 55.2, currency: Currency::USD); - - yield 'Convert unit enums to array' => [ - 'elements' => [Currency::USD, Currency::BRL], - 'expected' => [Currency::USD->name, Currency::BRL->name] - ]; - - yield 'Convert mixed types to array' => [ - 'elements' => ['iPhone', 42, true, $amountInUsd], - 'expected' => [ - 'iPhone', - 42, - true, - ['value' => $amountInUsd->value, 'currency' => $amountInUsd->currency->name] - ] - ]; - - yield 'Convert backed enums to array' => [ - 'elements' => [Status::PAID, Status::PENDING], - 'expected' => [Status::PAID->value, Status::PENDING->value] - ]; - - yield 'Convert nested arrays to array' => [ - 'elements' => [ - ['name' => 'Item 1', 'details' => ['price' => 100, 'stock' => 10]], - ['name' => 'Item 2', 'details' => ['price' => 200, 'stock' => 5]] - ], - 'expected' => [ - ['name' => 'Item 1', 'details' => ['price' => 100, 'stock' => 10]], - ['name' => 'Item 2', 'details' => ['price' => 200, 'stock' => 5]] - ] - ]; - - yield 'Convert boolean values to array' => [ - 'elements' => [true, false], - 'expected' => [true, false] - ]; - - yield 'Convert empty collection to array' => [ - 'elements' => [], - 'expected' => [] - ]; - - yield 'Convert array of strings to array' => [ - 'elements' => ['iPhone', 'iPad', 'MacBook'], - 'expected' => ['iPhone', 'iPad', 'MacBook'] - ]; - - yield 'Convert array of integers to array' => [ - 'elements' => [1, 2, 3], - 'expected' => [1, 2, 3] - ]; - - yield 'Convert array of amount objects to array' => [ - 'elements' => [$amountInBrl, $amountInUsd], - 'expected' => [ - ['value' => $amountInBrl->value, 'currency' => $amountInBrl->currency->name], - ['value' => $amountInUsd->value, 'currency' => $amountInUsd->currency->name] - ] - ]; - } -} diff --git a/tests/Internal/Operations/Transform/CollectionMapToJsonOperationTest.php b/tests/Internal/Operations/Transform/CollectionMapToJsonOperationTest.php deleted file mode 100644 index c717f10..0000000 --- a/tests/Internal/Operations/Transform/CollectionMapToJsonOperationTest.php +++ /dev/null @@ -1,82 +0,0 @@ -toJson(); - - /** @Then the JSON representation should match the expected format */ - self::assertSame($expected, $actual); - } - - public static function elementsDataProvider(): iterable - { - $amountInBrl = new Amount(value: 55.1, currency: Currency::BRL); - $amountInUsd = new Amount(value: 55.2, currency: Currency::USD); - - yield 'Convert unit enums to JSON' => [ - 'elements' => [Currency::USD, Currency::BRL], - 'expected' => '["USD","BRL"]' - ]; - - yield 'Convert mixed types to JSON' => [ - 'elements' => ['iPhone', 42, true, $amountInUsd], - 'expected' => '["iPhone",42,true,{"value":55.2,"currency":"USD"}]' - ]; - - yield 'Convert backed enums to JSON' => [ - 'elements' => [Status::PAID, Status::PENDING], - 'expected' => '[1,0]' - ]; - - yield 'Convert nested arrays to JSON' => [ - 'elements' => [ - ['name' => 'Item 1', 'details' => ['price' => 100, 'stock' => 10]], - ['name' => 'Item 2', 'details' => ['price' => 200, 'stock' => 5]] - ], - 'expected' => '[{"name":"Item 1","details":{"price":100,"stock":10}},{"name":"Item 2","details":{"price":200,"stock":5}}]' - ]; - - yield 'Convert boolean values to JSON' => [ - 'elements' => [true, false], - 'expected' => '[true,false]' - ]; - - yield 'Convert empty collection to JSON' => [ - 'elements' => [], - 'expected' => '[]' - ]; - - yield 'Convert array of strings to JSON' => [ - 'elements' => ['iPhone', 'iPad', 'MacBook'], - 'expected' => '["iPhone","iPad","MacBook"]' - ]; - - yield 'Convert array of integers to JSON' => [ - 'elements' => [1, 2, 3], - 'expected' => '[1,2,3]' - ]; - - yield 'Convert array of amount objects to JSON' => [ - 'elements' => [$amountInBrl, $amountInUsd], - 'expected' => '[{"value":55.1,"currency":"BRL"},{"value":55.2,"currency":"USD"}]' - ]; - } -} diff --git a/tests/Internal/Operations/Write/CollectionAddOperationTest.php b/tests/Internal/Operations/Write/CollectionAddOperationTest.php deleted file mode 100644 index ac1c8e6..0000000 --- a/tests/Internal/Operations/Write/CollectionAddOperationTest.php +++ /dev/null @@ -1,128 +0,0 @@ -add(...$addElements); - - /** @Then the collection should contain the expected elements */ - self::assertSame($expected, $collection->toArray()); - } - - public static function elementsAndAdditionsDataProvider(): iterable - { - $dragonOne = new Dragon(name: 'Udron', description: 'The taker of life.'); - $dragonTwo = new Dragon(name: 'Ignarion', description: 'Majestic guardian of fiery realms.'); - $dragonThree = new Dragon(name: 'Ignivar Bloodwing', description: 'Fierce guardian of volcanic mountains.'); - - $bitcoin = new CryptoCurrency(name: 'Bitcoin', price: (float)rand(60000, 999999), symbol: 'BTC'); - $ethereum = new CryptoCurrency(name: 'Ethereum', price: (float)rand(10000, 60000), symbol: 'ETH'); - - $productOne = new Product(name: 'Product One', amount: new Amount(value: 100.50, currency: Currency::USD)); - $productTwo = new Product(name: 'Product Two', amount: new Amount(value: 200.75, currency: Currency::BRL)); - - $orderOne = new Order(id: 1, products: new Products(elements: [$productOne, $productTwo])); - $orderTwo = new Order(id: 2, products: new Products(elements: [$productOne])); - - yield 'Add null value' => [ - 'fromElements' => [], - 'addElements' => [null], - 'expected' => [null] - ]; - - yield 'Add single array' => [ - 'fromElements' => [], - 'addElements' => [[1, 2, 3]], - 'expected' => [[1, 2, 3]] - ]; - - yield 'Add single float' => [ - 'fromElements' => [], - 'addElements' => [3.14], - 'expected' => [3.14] - ]; - - yield 'Add single string' => [ - 'fromElements' => [], - 'addElements' => ['test'], - 'expected' => ['test'] - ]; - - yield 'Add orders objects' => [ - 'fromElements' => [$orderOne], - 'addElements' => [$orderTwo], - 'expected' => [ - [ - 'id' => 1, - 'products' => [$productOne->toArray(), $productTwo->toArray()] - ], - [ - 'id' => 2, - 'products' => [$productOne->toArray()] - ] - ] - ]; - - yield 'Add dragon objects' => [ - 'fromElements' => [$dragonOne], - 'addElements' => [$dragonTwo, $dragonThree], - 'expected' => [ - ['name' => $dragonOne->name, 'description' => $dragonOne->description], - ['name' => $dragonTwo->name, 'description' => $dragonTwo->description], - ['name' => $dragonThree->name, 'description' => $dragonThree->description] - ] - ]; - - yield 'Add boolean values' => [ - 'fromElements' => [], - 'addElements' => [true, false], - 'expected' => [true, false] - ]; - - yield 'Add single integer' => [ - 'fromElements' => [], - 'addElements' => [42], - 'expected' => [42] - ]; - - yield 'Add crypto currency objects' => [ - 'fromElements' => [$bitcoin], - 'addElements' => [$ethereum], - 'expected' => [ - ['name' => $bitcoin->name, 'price' => $bitcoin->price, 'symbol' => $bitcoin->symbol], - ['name' => $ethereum->name, 'price' => $ethereum->price, 'symbol' => $ethereum->symbol] - ] - ]; - - yield 'Add mixed types of primitives' => [ - 'fromElements' => [], - 'addElements' => [42, 'test', 3.14, null, [1, 2, 3]], - 'expected' => [42, 'test', 3.14, null, [1, 2, 3]] - ]; - } -} diff --git a/tests/Internal/Operations/Write/CollectionCreateOperationTest.php b/tests/Internal/Operations/Write/CollectionCreateOperationTest.php deleted file mode 100644 index 31dfe9b..0000000 --- a/tests/Internal/Operations/Write/CollectionCreateOperationTest.php +++ /dev/null @@ -1,47 +0,0 @@ -isEmpty()); - } - - public function testCreatingCollectionFromExistingCollection(): void - { - /** @Given a collection of cryptocurrencies */ - $collection = Collection::createFrom(elements: [ - new CryptoCurrency(name: 'Bitcoin', price: 60000.0, symbol: 'BTC'), - new CryptoCurrency(name: 'Ethereum', price: 40000.0, symbol: 'ETH'), - ]); - - /** @When creating another collection from the existing collection */ - $collectionB = Collection::createFrom(elements: $collection); - - /** @Then the new collection should have the same number of elements */ - self::assertCount($collection->count(), $collectionB); - - /** @And both collections should be equal */ - self::assertTrue($collectionB->equals(other: $collection)); - - /** @And the elements in both collections should match */ - foreach ($collection as $index => $element) { - self::assertEquals($element, $collectionB->getBy(index: $index)); - } - - /** @And the two collections should not be the same instance */ - self::assertNotSame($collection, $collectionB); - } -} diff --git a/tests/Internal/Operations/Write/CollectionMergeOperationTest.php b/tests/Internal/Operations/Write/CollectionMergeOperationTest.php deleted file mode 100644 index d0eebd5..0000000 --- a/tests/Internal/Operations/Write/CollectionMergeOperationTest.php +++ /dev/null @@ -1,116 +0,0 @@ -merge(other: $collectionB); - - /** @Then the result should be an empty collection */ - self::assertEmpty($actual->toArray()); - } - - public function testMergeIntoEmptyCollection(): void - { - /** @Given an empty collection and a non-empty collection */ - $collectionA = Collection::createFromEmpty(); - $collectionB = Collection::createFrom(elements: [4, 5, 6]); - - /** @When merging the non-empty collection into the empty one */ - $actual = $collectionA->merge(other: $collectionB); - - /** @Then the result should contain only the elements from the non-empty collection */ - self::assertSame([4, 5, 6], $actual->toArray()); - } - - public function testMergeWithEmptyCollection(): void - { - /** @Given a non-empty collection and an empty collection */ - $collectionA = Collection::createFrom(elements: [1, 2, 3]); - $collectionB = Collection::createFromEmpty(); - - /** @When merging the empty collection into the non-empty one */ - $actual = $collectionA->merge(other: $collectionB); - - /** @Then the result should contain only the original elements */ - self::assertSame([1, 2, 3], $actual->toArray()); - } - - public function testMergeTwoCollections(): void - { - /** @Given two collections with distinct elements */ - $collectionA = Collection::createFrom(elements: [1, 2, 3]); - $collectionB = Collection::createFrom(elements: [4, 5, 6]); - - /** @When merging collection B into collection A */ - $actual = $collectionA->merge(other: $collectionB); - - /** @Then the result should contain all elements in order */ - self::assertSame([1, 2, 3, 4, 5, 6], $actual->toArray()); - } - - public function testMergePreservesLazyEvaluation(): void - { - /** @Given two collections created from generators */ - $collectionA = Collection::createFrom( - elements: (static function () { - yield 1; - yield 2; - })() - ); - - $collectionB = Collection::createFrom( - elements: (static function () { - yield 3; - yield 4; - })() - ); - - /** @When merging and retrieving only the first element */ - $actual = $collectionA->merge(other: $collectionB)->first(); - - /** @Then the first element should be from collection A without materializing all elements */ - self::assertSame(1, $actual); - } - - public function testMergeMultipleCollections(): void - { - /** @Given three collections */ - $collectionA = Collection::createFrom(elements: [1, 2]); - $collectionB = Collection::createFrom(elements: [3, 4]); - $collectionC = Collection::createFrom(elements: [5, 6]); - - /** @When chaining multiple merge operations */ - $actual = $collectionA - ->merge(other: $collectionB) - ->merge(other: $collectionC); - - /** @Then the result should contain all elements in order */ - self::assertSame([1, 2, 3, 4, 5, 6], $actual->toArray()); - } - - public function testMergeWithDuplicateElements(): void - { - /** @Given two collections with overlapping elements */ - $collectionA = Collection::createFrom(elements: [1, 2, 3]); - $collectionB = Collection::createFrom(elements: [3, 4, 5]); - - /** @When merging the collections */ - $actual = $collectionA->merge(other: $collectionB); - - /** @Then the result should contain all elements including duplicates */ - self::assertSame([1, 2, 3, 3, 4, 5], $actual->toArray()); - } -} diff --git a/tests/Internal/Operations/Write/CollectionRemoveOperationTest.php b/tests/Internal/Operations/Write/CollectionRemoveOperationTest.php deleted file mode 100644 index e977021..0000000 --- a/tests/Internal/Operations/Write/CollectionRemoveOperationTest.php +++ /dev/null @@ -1,125 +0,0 @@ -removeAll(); - - /** @Then the collection should be empty */ - self::assertEmpty($actual->toArray()); - } - - #[DataProvider('elementRemovalDataProvider')] - public function testRemoveSpecificElement(mixed $element, iterable $elements, iterable $expected): void - { - /** @Given a collection created from initial elements */ - $collection = Collection::createFrom(elements: $elements); - - /** @When removing the specified element */ - $actual = $collection->remove(element: $element); - - /** @Then the collection should no longer contain the removed element */ - self::assertSame($expected, $actual->toArray()); - } - - public function testRemoveSpecificElementUsingFilter(): void - { - /** @Given a Bitcoin (BTC) */ - $bitcoin = new CryptoCurrency(name: 'Bitcoin', price: (float)rand(60000, 999999), symbol: 'BTC'); - - /** @And an Ethereum (ETH) */ - $ethereum = new CryptoCurrency(name: 'Ethereum', price: (float)rand(10000, 60000), symbol: 'ETH'); - - /** @And a collection containing these elements */ - $collection = Collection::createFrom(elements: [$bitcoin, $ethereum]); - - /** @When removing the Bitcoin (BTC) element using a filter */ - $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()); - } - - public static function elementRemovalDataProvider(): iterable - { - $dragonOne = new Dragon(name: 'Udron', description: 'The taker of life.'); - $dragonTwo = new Dragon(name: 'Ignarion', description: 'Majestic guardian of fiery realms.'); - - $bitcoin = new CryptoCurrency(name: 'Bitcoin', price: (float)rand(60000, 999999), symbol: 'BTC'); - $ethereum = new CryptoCurrency(name: 'Ethereum', price: (float)rand(10000, 60000), symbol: 'ETH'); - - $spTimeZone = new DateTimeZone('America/Sao_Paulo'); - $dateOne = DateTimeImmutable::createFromFormat('Y-m-d H:i:s', '1997-01-01 00:00:00', $spTimeZone); - $dateTwo = DateTimeImmutable::createFromFormat('Y-m-d H:i:s', '1997-01-02 00:00:00', $spTimeZone); - - yield 'Remove enum from collection' => [ - 'element' => Currency::BRL, - 'elements' => [Currency::BRL, Status::PAID, Currency::USD, Status::PENDING], - 'expected' => [Status::PAID->value, Currency::USD->name, Status::PENDING->value] - ]; - - yield 'Remove null from collection' => [ - 'element' => null, - 'elements' => [$ethereum, null, $bitcoin], - 'expected' => [$ethereum->toArray(), $bitcoin->toArray()] - ]; - - yield 'Remove date from collection' => [ - 'element' => $dateOne, - 'elements' => [$dateOne, $dateTwo], - 'expected' => ['1997-01-02T00:00:00-02:00'] - ]; - - yield 'Remove dragon from collection' => [ - 'element' => $dragonOne, - 'elements' => [$dragonOne, $dragonTwo], - 'expected' => [ - ['name' => $dragonTwo->name, 'description' => $dragonTwo->description] - ] - ]; - - yield 'Remove scalar values from collection' => [ - 'element' => 50, - 'elements' => [true, 100, 'xpto', 50, 1000.0001, null, ['id' => 1]], - 'expected' => [true, 100, 'xpto', 1000.0001, null, ['id' => 1]] - ]; - - yield 'Remove crypto currency from collection' => [ - 'element' => $bitcoin, - 'elements' => [$ethereum, null, $bitcoin], - 'expected' => [$ethereum->toArray(), null] - ]; - - yield 'Remove list of elements from array iterators' => [ - 'element' => [4, 5, 6], - 'elements' => new ArrayIterator([[1, 2, 3], [4, 5, 6], [7, 8, 9]]), - 'expected' => [[1, 2, 3], [7, 8, 9]] - ]; - } -} diff --git a/tests/LazyCollectionTest.php b/tests/LazyCollectionTest.php new file mode 100644 index 0000000..ee4b0c4 --- /dev/null +++ b/tests/LazyCollectionTest.php @@ -0,0 +1,1016 @@ +count()); + + /** @And the array should match the original elements */ + self::assertSame([1, 2, 3], $collection->toArray()); + } + + public function testFromEmpty(): void + { + /** @When creating a lazy collection without arguments */ + $collection = Collection::createLazyFromEmpty(); + + /** @Then the collection should be empty */ + self::assertTrue($collection->isEmpty()); + + /** @And the count should be zero */ + self::assertSame(0, $collection->count()); + } + + public function testFromGenerator(): void + { + /** @Given a generator that yields three elements */ + $generator = (static function (): Generator { + yield 1; + yield 2; + yield 3; + })(); + + /** @When creating a lazy collection from the generator */ + $collection = Collection::createLazyFrom(elements: $generator); + + /** @Then the collection should contain all three elements */ + self::assertSame(3, $collection->count()); + } + + public function testAdd(): void + { + /** @Given a lazy collection with three elements */ + $collection = Collection::createLazyFrom(elements: [1, 2, 3]); + + /** @When adding two more elements */ + $actual = $collection->add(4, 5); + + /** @Then the new collection should contain five elements */ + self::assertSame(5, $actual->count()); + + /** @And the elements should be in the expected order */ + self::assertSame([1, 2, 3, 4, 5], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + + /** @And the original collection should remain unchanged */ + self::assertSame(3, $collection->count()); + } + + public function testConcat(): void + { + /** @Given a first lazy collection */ + $first = Collection::createLazyFrom(elements: [1, 2]); + + /** @And a second lazy collection */ + $second = Collection::createLazyFrom(elements: [3, 4]); + + /** @When concatenating the second into the first */ + $actual = $first->merge(other: $second); + + /** @Then the resulting collection should contain four elements */ + self::assertSame(4, $actual->count()); + + /** @And the elements should be in the expected order */ + self::assertSame([1, 2, 3, 4], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + } + + public function testContainsExistingElement(): void + { + /** @Given a lazy collection with integers */ + $collection = Collection::createLazyFrom(elements: [1, 2, 3]); + + /** @When checking for an element that exists */ + $actual = $collection->contains(element: 2); + + /** @Then it should return true */ + self::assertTrue($actual); + } + + public function testContainsMissingElement(): void + { + /** @Given a lazy collection with integers */ + $collection = Collection::createLazyFrom(elements: [1, 2, 3]); + + /** @When checking for an element that does not exist */ + $actual = $collection->contains(element: 99); + + /** @Then it should return false */ + self::assertFalse($actual); + } + + public function testContainsObject(): void + { + /** @Given an Amount object to search for */ + $target = new Amount(value: 100.00, currency: Currency::USD); + + /** @And a lazy collection with Amount objects */ + $collection = Collection::createLazyFrom(elements: [ + new Amount(value: 50.00, currency: Currency::USD), + new Amount(value: 100.00, currency: Currency::USD), + new Amount(value: 200.00, currency: Currency::USD) + ]); + + /** @When checking if the collection contains an equivalent Amount */ + $actual = $collection->contains(element: $target); + + /** @Then it should return true */ + self::assertTrue($actual); + } + + public function testContainsObjectDoesNotMatchTrueScalar(): void + { + /** @Given a lazy collection containing boolean true */ + $collection = Collection::createLazyFrom(elements: [true]); + + /** @When checking if the collection contains an object */ + $actual = $collection->contains(element: new stdClass()); + + /** @Then it should return false because object and scalar types differ */ + self::assertFalse($actual); + } + + public function testCollectionWithObjectDoesNotContainTrueScalar(): void + { + /** @Given a lazy collection containing a stdClass object */ + $collection = Collection::createLazyFrom(elements: [new stdClass()]); + + /** @When checking if the collection contains boolean true */ + $actual = $collection->contains(element: true); + + /** @Then it should return false because an object is not a scalar */ + self::assertFalse($actual); + } + + public function testCount(): void + { + /** @Given a lazy collection with five elements */ + $collection = Collection::createLazyFrom(elements: [1, 2, 3, 4, 5]); + + /** @When counting the elements */ + $actual = $collection->count(); + + /** @Then it should return 5 */ + self::assertSame(5, $actual); + } + + public function testFindFirstMatch(): void + { + /** @Given a lazy collection of integers */ + $collection = Collection::createLazyFrom(elements: [1, 2, 3, 4, 5]); + + /** @When finding the first element greater than 3 */ + $actual = $collection->findBy(predicates: static fn(int $value): bool => $value > 3); + + /** @Then it should return 4 */ + self::assertSame(4, $actual); + } + + public function testFindReturnsNullWhenNoMatch(): void + { + /** @Given a lazy collection of integers */ + $collection = Collection::createLazyFrom(elements: [1, 2, 3]); + + /** @When finding an element greater than 100 */ + $actual = $collection->findBy(predicates: static fn(int $value): bool => $value > 100); + + /** @Then it should return null */ + self::assertNull($actual); + } + + public function testEach(): void + { + /** @Given a lazy collection of integers */ + $collection = Collection::createLazyFrom(elements: [1, 2, 3]); + + /** @And a variable to accumulate a sum */ + $sum = 0; + + /** @When using each to accumulate the sum */ + $actual = $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 + { + /** @Given a first lazy collection */ + $first = Collection::createLazyFrom(elements: [1, 2, 3]); + + /** @And a second lazy collection with the same elements */ + $second = Collection::createLazyFrom(elements: [1, 2, 3]); + + /** @When comparing them for equality */ + $actual = $first->equals(other: $second); + + /** @Then they should be equal */ + self::assertTrue($actual); + } + + public function testEqualsWithDifferentCollections(): void + { + /** @Given a first lazy collection */ + $first = Collection::createLazyFrom(elements: [1, 2, 3]); + + /** @And a second lazy collection with different elements */ + $second = Collection::createLazyFrom(elements: [1, 2, 4]); + + /** @When comparing them for equality */ + $actual = $first->equals(other: $second); + + /** @Then they should not be equal */ + self::assertFalse($actual); + } + + public function testEqualsWithDifferentSizes(): void + { + /** @Given a first lazy collection with three elements */ + $first = Collection::createLazyFrom(elements: [1, 2, 3]); + + /** @And a second lazy collection with two elements */ + $second = Collection::createLazyFrom(elements: [1, 2]); + + /** @When comparing first equals second */ + $firstEqualsSecond = $first->equals(other: $second); + + /** @And comparing second equals first */ + $secondEqualsFirst = $second->equals(other: $first); + + /** @Then the first comparison should return false */ + self::assertFalse($firstEqualsSecond); + + /** @And the second comparison should return false */ + self::assertFalse($secondEqualsFirst); + } + + public function testEqualsWithDifferentSizesButSamePrefix(): void + { + /** @Given a first lazy collection with four elements */ + $first = Collection::createLazyFrom(elements: [1, 2, 3, 4]); + + /** @And a second lazy collection with three elements */ + $second = Collection::createLazyFrom(elements: [1, 2, 3]); + + /** @When comparing them for equality */ + $actual = $first->equals(other: $second); + + /** @Then they should not be equal */ + self::assertFalse($actual); + } + + public function testEqualsWithNullElementsAndDifferentSizes(): void + { + /** @Given a first lazy collection with three elements */ + $first = Collection::createLazyFrom(elements: [1, 2, 3]); + + /** @And a second lazy collection with four elements ending with null */ + $second = Collection::createLazyFrom(elements: [1, 2, 3, null]); + + /** @When comparing them for equality */ + $actual = $first->equals(other: $second); + + /** @Then they should not be equal */ + self::assertFalse($actual); + } + + public function testRemoveElement(): void + { + /** @Given a lazy collection with duplicate elements */ + $collection = Collection::createLazyFrom(elements: [1, 2, 3, 2, 4]); + + /** @When removing the value 2 */ + $actual = $collection->remove(element: 2); + + /** @Then all occurrences of 2 should be removed */ + self::assertSame([1, 3, 4], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + } + + public function testRemoveScalarFromObjectCollection(): void + { + /** @Given a lazy collection with Amount objects */ + $collection = Collection::createLazyFrom(elements: [ + new Amount(value: 50.00, currency: Currency::USD), + new Amount(value: 100.00, currency: Currency::USD) + ]); + + /** @When removing a scalar value */ + $actual = $collection->remove(element: 50.00); + + /** @Then no elements should be removed */ + self::assertSame(2, $actual->count()); + } + + public function testRemovePreservesKeys(): void + { + /** @Given a lazy collection with string keys */ + $collection = Collection::createLazyFrom(elements: ['a' => 1, 'b' => 2, 'c' => 3]); + + /** @When removing the value 2 */ + $actual = $collection->remove(element: 2); + + /** @Then the remaining keys should be preserved */ + self::assertSame(['a' => 1, 'c' => 3], $actual->toArray()); + } + + public function testRemoveAllWithPredicate(): void + { + /** @Given a lazy collection of integers */ + $collection = Collection::createLazyFrom(elements: [1, 2, 3, 4, 5]); + + /** @When removing all elements greater than 3 */ + $actual = $collection->removeAll(predicate: static fn(int $value): bool => $value > 3); + + /** @Then only elements 1, 2, 3 should remain */ + self::assertSame([1, 2, 3], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + } + + public function testRemoveAllWithoutPredicate(): void + { + /** @Given a lazy collection of integers */ + $collection = Collection::createLazyFrom(elements: [1, 2, 3]); + + /** @When removing all without a predicate */ + $actual = $collection->removeAll(); + + /** @Then the collection should be empty */ + self::assertSame(0, $actual->count()); + } + + public function testRemoveAllWithNonMatchingFirstElement(): void + { + /** @Given a lazy collection where the first element does not match the predicate */ + $collection = Collection::createLazyFrom(elements: [1, 10, 2, 20, 3]); + + /** @When removing all elements greater than 5 */ + $actual = $collection->removeAll(predicate: static fn(int $value): bool => $value > 5); + + /** @Then elements 1, 2, 3 should remain */ + self::assertSame([1, 2, 3], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + } + + public function testRemoveAllPreservesKeys(): void + { + /** @Given a lazy collection with string keys */ + $collection = Collection::createLazyFrom(elements: ['a' => 1, 'b' => 2, 'c' => 3]); + + /** @When removing elements greater than 2 */ + $actual = $collection->removeAll(predicate: static fn(int $value): bool => $value > 2); + + /** @Then the remaining keys should be preserved */ + self::assertSame(['a' => 1, 'b' => 2], $actual->toArray()); + } + + public function testFirstReturnsElement(): void + { + /** @Given a lazy collection with three elements */ + $collection = Collection::createLazyFrom(elements: [10, 20, 30]); + + /** @When retrieving the first element */ + $actual = $collection->first(); + + /** @Then it should return 10 */ + self::assertSame(10, $actual); + } + + public function testFirstReturnsDefaultWhenEmpty(): void + { + /** @Given an empty lazy collection */ + $collection = Collection::createLazyFromEmpty(); + + /** @When retrieving the first element with a default */ + $actual = $collection->first(defaultValueIfNotFound: 'fallback'); + + /** @Then it should return the default value */ + self::assertSame('fallback', $actual); + } + + public function testFirstReturnsNullWhenEmpty(): void + { + /** @Given an empty lazy collection */ + $collection = Collection::createLazyFromEmpty(); + + /** @When retrieving the first element without a default */ + $actual = $collection->first(); + + /** @Then it should return null */ + self::assertNull($actual); + } + + public function testFirstReturnsNullElementInsteadOfDefault(): void + { + /** @Given a lazy collection where the first element is null */ + $collection = Collection::createLazyFrom(elements: [null, 1, 2]); + + /** @When retrieving the first element with a default */ + $actual = $collection->first(defaultValueIfNotFound: 'fallback'); + + /** @Then it should return null, not the default */ + self::assertNull($actual); + } + + public function testFlatten(): void + { + /** @Given a lazy collection with nested arrays */ + $collection = Collection::createLazyFrom(elements: [[1, 2], [3, 4], 5]); + + /** @When flattening by one level */ + $actual = $collection->flatten(); + + /** @Then all elements should be at the top level */ + self::assertSame([1, 2, 3, 4, 5], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + } + + public function testGetByIndex(): void + { + /** @Given a lazy collection with three elements */ + $collection = Collection::createLazyFrom(elements: ['a', 'b', 'c']); + + /** @When retrieving the element at index 1 */ + $actual = $collection->getBy(index: 1); + + /** @Then it should return 'b' */ + self::assertSame('b', $actual); + } + + public function testGetByIndexReturnsDefaultWhenOutOfBounds(): void + { + /** @Given a lazy collection with three elements */ + $collection = Collection::createLazyFrom(elements: ['a', 'b', 'c']); + + /** @When retrieving an element at an index that does not exist */ + $actual = $collection->getBy(index: 99, defaultValueIfNotFound: 'missing'); + + /** @Then it should return the default value */ + self::assertSame('missing', $actual); + } + + public function testGroupBy(): void + { + /** @Given a lazy collection of integers from 1 to 6 */ + $collection = Collection::createLazyFrom(elements: [1, 2, 3, 4, 5, 6]); + + /** @When grouping by even and odd */ + $actual = $collection->groupBy( + classifier: static fn(int $value): string => $value % 2 === 0 ? 'even' : 'odd' + ); + + /** @Then the odd group should contain 1, 3, 5 */ + $groups = $actual->toArray(); + self::assertSame([1, 3, 5], $groups['odd']); + + /** @And the even group should contain 2, 4, 6 */ + self::assertSame([2, 4, 6], $groups['even']); + } + + public function testIsEmpty(): void + { + /** @Given an empty lazy collection */ + $empty = Collection::createLazyFromEmpty(); + + /** @Then the empty collection should return true */ + self::assertTrue($empty->isEmpty()); + } + + public function testIsNotEmpty(): void + { + /** @Given a non-empty lazy collection */ + $nonEmpty = Collection::createLazyFrom(elements: [1]); + + /** @Then the non-empty collection should return false */ + self::assertFalse($nonEmpty->isEmpty()); + } + + public function testJoinToString(): void + { + /** @Given a lazy collection of strings */ + $collection = Collection::createLazyFrom(elements: ['a', 'b', 'c']); + + /** @When joining with a comma separator */ + $actual = $collection->joinToString(separator: ', '); + + /** @Then the result should be "a, b, c" */ + self::assertSame('a, b, c', $actual); + } + + public function testJoinToStringWithIntegers(): void + { + /** @Given a lazy collection of integers */ + $collection = Collection::createLazyFrom(elements: [1, 2, 3]); + + /** @When joining with a comma separator */ + $actual = $collection->joinToString(separator: ', '); + + /** @Then the result should be "1, 2, 3" */ + self::assertSame('1, 2, 3', $actual); + } + + public function testJoinToStringWithSingleInteger(): void + { + /** @Given a lazy collection with a single integer */ + $collection = Collection::createLazyFrom(elements: [42]); + + /** @When joining to string */ + $actual = $collection->joinToString(separator: ', '); + + /** @Then the result should be a string */ + self::assertSame('42', $actual); + } + + public function testFilterWithPredicate(): void + { + /** @Given a lazy collection of integers */ + $collection = Collection::createLazyFrom(elements: [1, 2, 3, 4, 5]); + + /** @When keeping only elements greater than 3 */ + $actual = $collection->filter(predicates: static fn(int $value): bool => $value > 3); + + /** @Then only 4 and 5 should remain */ + self::assertSame([4, 5], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + } + + public function testFilterWithoutPredicateRemovesFalsyValues(): void + { + /** @Given a lazy collection with falsy and truthy values */ + $collection = Collection::createLazyFrom(elements: [0, '', null, false, 1, 'hello', 2]); + + /** @When filtering without a predicate */ + $actual = $collection->filter(); + + /** @Then only truthy values should remain */ + self::assertSame([1, 'hello', 2], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + } + + public function testFilterWithExplicitNull(): void + { + /** @Given a lazy collection with falsy and truthy values */ + $collection = Collection::createLazyFrom(elements: [0, '', 1, 'hello', 2]); + + /** @When filtering with an explicit null predicate */ + $actual = $collection->filter(null); + + /** @Then only truthy values should remain */ + self::assertSame([1, 'hello', 2], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + } + + public function testFilterPreservesKeys(): void + { + /** @Given a lazy collection with string keys */ + $collection = Collection::createLazyFrom(elements: ['a' => 1, 'b' => 2, 'c' => 3]); + + /** @When filtering only elements greater than 1 */ + $actual = $collection->filter(predicates: static fn(int $value): bool => $value > 1); + + /** @Then the remaining keys should be preserved */ + self::assertSame(['b' => 2, 'c' => 3], $actual->toArray()); + } + + public function testLastReturnsElement(): void + { + /** @Given a lazy collection with three elements */ + $collection = Collection::createLazyFrom(elements: [10, 20, 30]); + + /** @When retrieving the last element */ + $actual = $collection->last(); + + /** @Then it should return 30 */ + self::assertSame(30, $actual); + } + + public function testLastReturnsDefaultWhenEmpty(): void + { + /** @Given an empty lazy collection */ + $collection = Collection::createLazyFromEmpty(); + + /** @When retrieving the last element with a default */ + $actual = $collection->last(defaultValueIfNotFound: 'fallback'); + + /** @Then it should return the default value */ + self::assertSame('fallback', $actual); + } + + public function testLastReturnsNullElementInsteadOfDefault(): void + { + /** @Given a lazy collection where the last element is null */ + $collection = Collection::createLazyFrom(elements: [1, 2, null]); + + /** @When retrieving the last element with a default */ + $actual = $collection->last(defaultValueIfNotFound: 'fallback'); + + /** @Then it should return null, not the default */ + self::assertNull($actual); + } + + public function testMap(): void + { + /** @Given a lazy collection of integers */ + $collection = Collection::createLazyFrom(elements: [1, 2, 3]); + + /** @When transforming each element by multiplying by 10 */ + $actual = $collection->map(transformations: static fn(int $value): int => $value * 10); + + /** @Then each element should be multiplied */ + self::assertSame([10, 20, 30], $actual->toArray()); + } + + public function testMapPreservesKeys(): void + { + /** @Given a lazy collection with string keys */ + $collection = Collection::createLazyFrom(elements: ['a' => 1, 'b' => 2, 'c' => 3]); + + /** @When transforming each element */ + $actual = $collection->map(transformations: static fn(int $value): int => $value * 10); + + /** @Then the keys should be preserved */ + self::assertSame(['a' => 10, 'b' => 20, 'c' => 30], $actual->toArray()); + } + + public function testReduce(): void + { + /** @Given a lazy collection of integers */ + $collection = Collection::createLazyFrom(elements: [1, 2, 3, 4]); + + /** @When reducing to calculate the sum */ + $actual = $collection->reduce( + accumulator: static fn(int $carry, int $value): int => $carry + $value, + initial: 0 + ); + + /** @Then the sum should be 10 */ + self::assertSame(10, $actual); + } + + public function testSortAscending(): void + { + /** @Given a lazy collection with unordered elements */ + $collection = Collection::createLazyFrom(elements: [3, 1, 2]); + + /** @When sorting in ascending order by value */ + $actual = $collection->sort(order: Order::ASCENDING_VALUE); + + /** @Then the elements should be in ascending order */ + self::assertSame([1, 2, 3], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + } + + public function testSortDescending(): void + { + /** @Given a lazy collection with ordered elements */ + $collection = Collection::createLazyFrom(elements: [1, 2, 3]); + + /** @When sorting in descending order by value */ + $actual = $collection->sort(order: Order::DESCENDING_VALUE); + + /** @Then the elements should be in descending order */ + self::assertSame([3, 2, 1], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + } + + public function testSortAscendingKey(): void + { + /** @Given a lazy collection with unordered string keys */ + $collection = Collection::createLazyFrom(elements: ['c' => 3, 'a' => 1, 'b' => 2]); + + /** @When sorting by ascending key */ + $actual = $collection->sort(); + + /** @Then the keys should be in ascending order */ + self::assertSame(['a' => 1, 'b' => 2, 'c' => 3], $actual->toArray()); + } + + public function testSortDescendingKey(): void + { + /** @Given a lazy collection with ordered string keys */ + $collection = Collection::createLazyFrom(elements: ['a' => 1, 'b' => 2, 'c' => 3]); + + /** @When sorting by descending key */ + $actual = $collection->sort(order: Order::DESCENDING_KEY); + + /** @Then the keys should be in descending order */ + self::assertSame(['c' => 3, 'b' => 2, 'a' => 1], $actual->toArray()); + } + + public function testSortAscendingValueWithoutComparator(): void + { + /** @Given a lazy collection with unordered integers */ + $collection = Collection::createLazyFrom(elements: [3, 1, 4, 1, 5]); + + /** @When sorting ascending by value without a custom comparator */ + $actual = $collection->sort(order: Order::ASCENDING_VALUE); + + /** @Then the elements should be sorted by the default spaceship operator */ + self::assertSame([1, 1, 3, 4, 5], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + } + + public function testSortWithCustomComparator(): void + { + /** @Given a lazy collection of Amount objects */ + $collection = Collection::createLazyFrom(elements: [ + new Amount(value: 300.00, currency: Currency::USD), + new Amount(value: 100.00, currency: Currency::USD), + new Amount(value: 200.00, currency: Currency::USD) + ]); + + /** @When sorting ascending by value with a custom comparator */ + $actual = $collection->sort( + order: Order::ASCENDING_VALUE, + comparator: static fn(Amount $first, Amount $second): int => $first->value <=> $second->value + ); + + /** @Then the first element should have the lowest value */ + self::assertSame(100.00, $actual->first()->value); + + /** @And the last element should have the highest value */ + self::assertSame(300.00, $actual->last()->value); + } + + public function testSortWithCustomComparatorProducesDifferentOrderThanDefault(): void + { + /** @Given a lazy collection where alphabetical and length order diverge */ + $collection = Collection::createLazyFrom(elements: ['zz', 'a', 'bbb']); + + /** @When sorting ascending by length */ + $byLength = $collection->sort( + order: Order::ASCENDING_VALUE, + comparator: static fn(string $first, string $second): int => strlen($first) <=> strlen($second) + ); + + /** @And sorting ascending by default (alphabetical) */ + $byDefault = $collection->sort(order: Order::ASCENDING_VALUE); + + /** @Then the custom order should be by length */ + self::assertSame(['a', 'zz', 'bbb'], $byLength->toArray(keyPreservation: KeyPreservation::DISCARD)); + + /** @And the default order should be alphabetical */ + self::assertSame(['a', 'bbb', 'zz'], $byDefault->toArray(keyPreservation: KeyPreservation::DISCARD)); + } + + public function testSlice(): void + { + /** @Given a lazy collection of five elements */ + $collection = Collection::createLazyFrom(elements: [10, 20, 30, 40, 50]); + + /** @When slicing from offset 1 with length 2 */ + $actual = $collection->slice(offset: 1, length: 2); + + /** @Then the result should contain elements 20 and 30 */ + self::assertSame([20, 30], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + } + + public function testSliceUntilEnd(): void + { + /** @Given a lazy collection of five elements */ + $collection = Collection::createLazyFrom(elements: [10, 20, 30, 40, 50]); + + /** @When slicing from offset 2 without specifying length */ + $actual = $collection->slice(offset: 2); + + /** @Then the result should contain all elements from index 2 onward */ + self::assertSame([30, 40, 50], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + } + + public function testSlicePreservesKeys(): void + { + /** @Given a lazy collection with string keys */ + $collection = Collection::createLazyFrom(elements: ['a' => 10, 'b' => 20, 'c' => 30, 'd' => 40]); + + /** @When slicing from offset 1 with length 2 */ + $actual = $collection->slice(offset: 1, length: 2); + + /** @Then the keys should be preserved */ + self::assertSame(['b' => 20, 'c' => 30], $actual->toArray()); + } + + public function testSliceWithZeroLengthReturnsEmpty(): void + { + /** @Given a lazy collection with five elements */ + $collection = Collection::createLazyFrom(elements: [10, 20, 30, 40, 50]); + + /** @When slicing with length zero */ + $actual = $collection->slice(offset: 0, length: 0); + + /** @Then the result should be empty */ + self::assertTrue($actual->isEmpty()); + + /** @And the count should be zero */ + self::assertSame(0, $actual->count()); + } + + public function testSliceWithNegativeLengthExcludesTrailingElements(): void + { + /** @Given a lazy collection with five elements */ + $collection = Collection::createLazyFrom(elements: [10, 20, 30, 40, 50]); + + /** @When slicing from offset 0 with length -2 (exclude last 2) */ + $actual = $collection->slice(offset: 0, length: -2); + + /** @Then the result should contain the first three elements */ + self::assertSame([10, 20, 30], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + } + + public function testSliceWithOffsetAndNegativeLength(): void + { + /** @Given a lazy collection with five elements */ + $collection = Collection::createLazyFrom(elements: [10, 20, 30, 40, 50]); + + /** @When slicing from offset 1 with length -2 (skip first, exclude last 2) */ + $actual = $collection->slice(offset: 1, length: -2); + + /** @Then the result should contain elements 20 and 30 */ + self::assertSame([20, 30], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + } + + public function testSliceWithNegativeLengthPreservesKeys(): void + { + /** @Given a lazy collection with string keys */ + $collection = Collection::createLazyFrom(elements: ['a' => 10, 'b' => 20, 'c' => 30, 'd' => 40]); + + /** @When slicing from offset 0 with length -2 */ + $actual = $collection->slice(offset: 0, length: -2); + + /** @Then the keys should be preserved */ + self::assertSame(['a' => 10, 'b' => 20], $actual->toArray()); + } + + public function testSliceWithNegativeLengthProducesExactCount(): void + { + /** @Given a lazy collection with six elements */ + $collection = Collection::createLazyFrom(elements: [1, 2, 3, 4, 5, 6]); + + /** @When slicing from offset 0 with length -3 (exclude last 3) */ + $actual = $collection->slice(offset: 0, length: -3); + + /** @Then the collection should contain exactly 3 elements */ + self::assertCount(3, $actual); + + /** @And the elements should be 1, 2, 3 */ + self::assertSame([1, 2, 3], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + } + + public function testToArrayPreservingKeys(): void + { + /** @Given a lazy collection with non-sequential keys */ + $collection = Collection::createLazyFrom(elements: [0 => 'a', 2 => 'b', 5 => 'c']); + + /** @When converting to array preserving keys */ + $actual = $collection->toArray(); + + /** @Then the keys should be preserved */ + self::assertSame([0 => 'a', 2 => 'b', 5 => 'c'], $actual); + } + + public function testToArrayDiscardingKeys(): void + { + /** @Given a lazy collection with non-sequential keys */ + $collection = Collection::createLazyFrom(elements: [0 => 'a', 2 => 'b', 5 => 'c']); + + /** @When converting to array discarding keys */ + $actual = $collection->toArray(keyPreservation: KeyPreservation::DISCARD); + + /** @Then the keys should be re-indexed from 0 */ + self::assertSame(['a', 'b', 'c'], $actual); + } + + public function testToJson(): void + { + /** @Given a lazy collection of integers */ + $collection = Collection::createLazyFrom(elements: [1, 2, 3]); + + /** @When converting to JSON */ + $actual = $collection->toJson(); + + /** @Then the result should be a valid JSON array */ + self::assertSame('[1,2,3]', $actual); + } + + public function testToJsonDiscardingKeys(): void + { + /** @Given a lazy collection with string keys */ + $collection = Collection::createLazyFrom(elements: ['x' => 1, 'y' => 2]); + + /** @When converting to JSON discarding keys */ + $actual = $collection->toJson(keyPreservation: KeyPreservation::DISCARD); + + /** @Then the result should be a sequential JSON array */ + self::assertSame('[1,2]', $actual); + } + + public function testToJsonPreservesKeysByDefault(): void + { + /** @Given a lazy collection with string keys */ + $collection = Collection::createLazyFrom(elements: ['x' => 1, 'y' => 2]); + + /** @When converting to JSON without arguments */ + $actual = $collection->toJson(); + + /** @Then the result should preserve keys as a JSON object */ + self::assertSame('{"x":1,"y":2}', $actual); + } + + public function testImmutability(): void + { + /** @Given a lazy collection with three elements */ + $original = Collection::createLazyFrom(elements: [1, 2, 3]); + + /** @When adding a new element */ + $modified = $original->add(4); + + /** @Then the original collection should remain unchanged */ + self::assertSame(3, $original->count()); + + /** @And the new collection should have four elements */ + self::assertSame(4, $modified->count()); + } + + public function testChainedOperationsWithObjects(): void + { + /** @Given a lazy collection of Amount objects */ + $collection = Collection::createLazyFrom(elements: [ + new Amount(value: 50.00, currency: Currency::USD), + new Amount(value: 100.00, currency: Currency::USD), + new Amount(value: 150.00, currency: Currency::USD), + new Amount(value: 250.00, currency: Currency::USD), + new Amount(value: 500.00, currency: Currency::USD) + ]); + + /** @And a variable to accumulate the total discounted value */ + $totalDiscounted = 0.0; + + /** @When chaining filter, map, removeAll, sort and each */ + $actual = $collection + ->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(predicate: static fn(Amount $amount): bool => $amount->value > 300) + ->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; + }); + + /** @Then the final collection should contain exactly three elements */ + self::assertCount(3, $actual); + + /** @And the total discounted value should be 450 */ + self::assertSame(450.00, $totalDiscounted); + + /** @And the first Amount should be 90 after the discount */ + self::assertSame(90.00, $actual->first()->value); + + /** @And the last Amount should be 225 after the discount */ + self::assertSame(225.00, $actual->last()->value); + } + + public function testChainedOperationsWithIntegers(): void + { + /** @Given a lazy collection of integers from 1 to 100 */ + $collection = Collection::createLazyFrom(elements: range(1, 100)); + + /** @When keeping even numbers, squaring them, and sorting in descending order */ + $actual = $collection + ->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 should be 10000 (square of 100) */ + self::assertSame(10000, $actual->first()); + + /** @And the last element should be 4 (square of 2) */ + self::assertSame(4, $actual->last()); + + /** @When reducing to calculate the sum of all squared even numbers */ + $sum = $actual->reduce( + accumulator: static fn(int $carry, int $value): int => $carry + $value, + initial: 0 + ); + + /** @Then the sum should be 171700 */ + self::assertSame(171700, $sum); + } +} diff --git a/tests/Models/Carriers.php b/tests/Models/Carriers.php new file mode 100644 index 0000000..4ec437e --- /dev/null +++ b/tests/Models/Carriers.php @@ -0,0 +1,11 @@ +filter(predicates: static fn(InvoiceSummary $summary): bool => $summary->customer === $customer) ->reduce( - aggregator: static fn(float $carry, InvoiceSummary $summary): float => $carry + $summary->amount, + accumulator: static fn(float $carry, InvoiceSummary $summary): float => $carry + $summary->amount, initial: 0.0 ); } diff --git a/tests/Models/Invoices.php b/tests/Models/Invoices.php index 40a7319..dac4c10 100644 --- a/tests/Models/Invoices.php +++ b/tests/Models/Invoices.php @@ -8,4 +8,18 @@ final class Invoices extends Collection { + public function totalAmount(): float + { + return $this->reduce( + accumulator: static fn(float $carry, Invoice $invoice): float => $carry + $invoice->amount, + initial: 0.0 + ); + } + + public function forCustomer(string $customer): static + { + return $this->filter( + predicates: static fn(Invoice $invoice): bool => $invoice->customer === $customer + ); + } } From 37768ee219967fa7944427bd31521ebff8136ef3 Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Mon, 30 Mar 2026 07:19:27 -0300 Subject: [PATCH 2/3] feat: Add eager and lazy evaluation strategies with pipeline architecture. --- README.md | 3 ++- src/Collectible.php | 8 ++++---- tests/EagerCollectionTest.php | 27 +++++++++++++++++++++++++++ tests/LazyCollectionTest.php | 27 +++++++++++++++++++++++++++ 4 files changed, 60 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 1216edc..b96abea 100644 --- a/README.md +++ b/README.md @@ -233,7 +233,8 @@ elements, or finding elements that match a specific condition. #### Retrieve by condition -- `findBy`: Finds the first element that satisfies all given predicates, or returns null if no match is found. +- `findBy`: Finds the first element that satisfies any given predicate, or returns `null` if no predicate matches. + When called without predicates, it returns `null`. ```php $collection->findBy(predicates: static fn(CryptoCurrency $crypto): bool => $crypto->symbol === 'ETH'); diff --git a/src/Collectible.php b/src/Collectible.php index abce9a5..993800b 100644 --- a/src/Collectible.php +++ b/src/Collectible.php @@ -91,10 +91,10 @@ public function contains(mixed $element): bool; public function count(): int; /** - * Finds the first element that satisfies all given predicates. - * Without predicates, returns the first truthy element. + * Finds the first element that satisfies any given predicate. + * Without predicates, returns null. * - * @param Closure ...$predicates + * @param Closure ...$predicates Conditions to test each element against. * @return mixed The first matching element or null if no match is found. */ public function findBy(Closure ...$predicates): mixed; @@ -130,7 +130,7 @@ public function remove(mixed $element): static; /** * Returns a new collection with all elements removed that satisfy the given predicate. - * Without a predicate, all falsy values are removed. + * When no predicate is provided (i.e., $predicate is null), all elements are removed. * * @param Closure|null $predicate Condition to determine which elements to remove. * @return static A new collection with the matching elements removed. diff --git a/tests/EagerCollectionTest.php b/tests/EagerCollectionTest.php index bc68183..f8de439 100644 --- a/tests/EagerCollectionTest.php +++ b/tests/EagerCollectionTest.php @@ -185,6 +185,33 @@ public function testFindFirstMatch(): void self::assertSame(4, $actual); } + public function testFindFirstMatchAcrossMultiplePredicates(): void + { + /** @Given an eager collection of integers */ + $collection = Collection::createFrom(elements: [1, 2, 3, 4, 5]); + + /** @When finding by multiple predicates (OR semantics) */ + $matchTen = static fn(int $value): bool => $value === 10; + $matchThree = static fn(int $value): bool => $value === 3; + + $actual = $collection->findBy($matchTen, $matchThree); + + /** @Then it should return the first element matching any predicate */ + self::assertSame(3, $actual); + } + + public function testFindReturnsNullWithoutPredicates(): void + { + /** @Given an eager collection with truthy and falsy values */ + $collection = Collection::createFrom(elements: [0, 1, 2]); + + /** @When finding without predicates */ + $actual = $collection->findBy(); + + /** @Then it should return null */ + self::assertNull($actual); + } + public function testFindReturnsNullWhenNoMatch(): void { /** @Given an eager collection of integers */ diff --git a/tests/LazyCollectionTest.php b/tests/LazyCollectionTest.php index ee4b0c4..dfe324a 100644 --- a/tests/LazyCollectionTest.php +++ b/tests/LazyCollectionTest.php @@ -185,6 +185,33 @@ public function testFindFirstMatch(): void self::assertSame(4, $actual); } + public function testFindFirstMatchAcrossMultiplePredicates(): void + { + /** @Given a lazy collection of integers */ + $collection = Collection::createLazyFrom(elements: [1, 2, 3, 4, 5]); + + /** @When finding by multiple predicates (OR semantics) */ + $matchTen = static fn(int $value): bool => $value === 10; + $matchThree = static fn(int $value): bool => $value === 3; + + $actual = $collection->findBy($matchTen, $matchThree); + + /** @Then it should return the first element matching any predicate */ + self::assertSame(3, $actual); + } + + public function testFindReturnsNullWithoutPredicates(): void + { + /** @Given a lazy collection with truthy and falsy values */ + $collection = Collection::createLazyFrom(elements: [0, 1, 2]); + + /** @When finding without predicates */ + $actual = $collection->findBy(); + + /** @Then it should return null */ + self::assertNull($actual); + } + public function testFindReturnsNullWhenNoMatch(): void { /** @Given a lazy collection of integers */ From 87d8fea4770da8139e6768b1bee39078f48f8e74 Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Mon, 30 Mar 2026 07:34:47 -0300 Subject: [PATCH 3/3] feat: Add eager and lazy evaluation strategies with pipeline architecture. --- src/Collection.php | 5 ++--- src/Internal/EagerPipeline.php | 12 ++++++++++++ src/Internal/LazyPipeline.php | 16 ++++++++++++++++ src/Internal/Operations/Resolving/Get.php | 19 ------------------- src/Internal/Pipeline.php | 20 ++++++++++++++++++++ 5 files changed, 50 insertions(+), 22 deletions(-) delete mode 100644 src/Internal/Operations/Resolving/Get.php diff --git a/src/Collection.php b/src/Collection.php index 3e86c30..0533484 100644 --- a/src/Collection.php +++ b/src/Collection.php @@ -12,7 +12,6 @@ use TinyBlocks\Collection\Internal\Operations\Resolving\Equality; use TinyBlocks\Collection\Internal\Operations\Resolving\Find; use TinyBlocks\Collection\Internal\Operations\Resolving\First; -use TinyBlocks\Collection\Internal\Operations\Resolving\Get; use TinyBlocks\Collection\Internal\Operations\Resolving\Join; use TinyBlocks\Collection\Internal\Operations\Resolving\Last; use TinyBlocks\Collection\Internal\Operations\Resolving\Reduce; @@ -89,7 +88,7 @@ public function contains(mixed $element): bool public function count(): int { - return iterator_count($this->getIterator()); + return $this->pipeline->count(); } public function findBy(Closure ...$predicates): mixed @@ -136,7 +135,7 @@ public function flatten(): static public function getBy(int $index, mixed $defaultValueIfNotFound = null): mixed { - return Get::byIndex(elements: $this, index: $index, defaultValueIfNotFound: $defaultValueIfNotFound); + return $this->pipeline->getBy(index: $index, defaultValueIfNotFound: $defaultValueIfNotFound); } public function groupBy(Closure $classifier): static diff --git a/src/Internal/EagerPipeline.php b/src/Internal/EagerPipeline.php index 1a66c40..7dff0bc 100644 --- a/src/Internal/EagerPipeline.php +++ b/src/Internal/EagerPipeline.php @@ -34,6 +34,18 @@ public function pipe(Operation $operation): Pipeline return new EagerPipeline(elements: $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; diff --git a/src/Internal/LazyPipeline.php b/src/Internal/LazyPipeline.php index 806b275..a8cd6dc 100644 --- a/src/Internal/LazyPipeline.php +++ b/src/Internal/LazyPipeline.php @@ -39,6 +39,22 @@ public function pipe(Operation $operation): Pipeline return new LazyPipeline(source: $this->source, stages: $stages); } + public function count(): int + { + return iterator_count($this->process()); + } + + public function getBy(int $index, mixed $defaultValueIfNotFound = null): mixed + { + foreach ($this->process() as $currentIndex => $value) { + if ($currentIndex === $index) { + return $value; + } + } + + return $defaultValueIfNotFound; + } + public function process(): Generator { $elements = $this->source; diff --git a/src/Internal/Operations/Resolving/Get.php b/src/Internal/Operations/Resolving/Get.php deleted file mode 100644 index 03bfceb..0000000 --- a/src/Internal/Operations/Resolving/Get.php +++ /dev/null @@ -1,19 +0,0 @@ - $value) { - if ($currentIndex === $index) { - return $value; - } - } - - return $defaultValueIfNotFound; - } -} diff --git a/src/Internal/Pipeline.php b/src/Internal/Pipeline.php index dfe2ead..5f15edb 100644 --- a/src/Internal/Pipeline.php +++ b/src/Internal/Pipeline.php @@ -27,6 +27,26 @@ interface Pipeline */ public function pipe(Operation $operation): Pipeline; + /** + * Returns the total number of elements in the pipeline. + * + * Eager pipelines provide this in O(1), lazy pipelines must iterate. + * + * @return int The element count. + */ + public function count(): int; + + /** + * Returns the element at the given zero-based index. + * + * Eager pipelines provide this in O(1), lazy pipelines must iterate. + * + * @param int $index The zero-based position. + * @param mixed $defaultValueIfNotFound Value returned when the index is out of bounds. + * @return mixed The element at the index or the default. + */ + public function getBy(int $index, mixed $defaultValueIfNotFound = null): mixed; + /** * Executes all accumulated stages and yields the resulting elements. *