diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..77c2bb8 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,11 @@ +# Copilot instructions + +## Context + +PHP library (tiny-blocks). Immutable domain models, zero infrastructure dependencies in core. + +## Mandatory pre-task step + +Before starting any task, read and strictly follow all instruction files located in `.claude/CLAUDE.md` and +`.claude/rules/`. These files are the absolute source of truth for code generation. Apply every rule strictly. Do not +deviate from the patterns, folder structure, or naming conventions defined in them. diff --git a/README.md b/README.md index 4bf1eb2..e62e0ef 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ * [Comparing](#comparing) * [Aggregation](#aggregation) * [Transforming](#transforming) +* [Evaluation strategies](#evaluation-strategies) * [FAQ](#faq) * [License](#license) * [Contributing](#contributing) @@ -101,24 +102,28 @@ final class Invoices extends Collection } ``` -
- -#### Creating from a closure - -The `createLazyFromClosure` method creates a lazy collection backed by a closure that produces an iterable. The -closure is invoked each time the collection is iterated, enabling safe re-iteration over generators or other single-use -iterables. +### Creating collections ```php use TinyBlocks\Collection\Collection; -$collection = Collection::createLazyFromClosure(factory: static function (): iterable { +$eager = Collection::createFrom(elements: [1, 2, 3]); + +$eagerFromClosure = Collection::createFromClosure(factory: static function (): array { + return [1, 2, 3]; +}); + +$lazy = Collection::createLazyFrom(elements: [1, 2, 3]); + +$lazyFromClosure = Collection::createLazyFromClosure(factory: static function (): iterable { yield 1; yield 2; yield 3; }); ``` +
+ ## Writing These methods enable adding, removing, and modifying elements in the Collection. @@ -407,6 +412,133 @@ These methods allow the Collection's elements to be transformed or converted int $collection->toJson(keyPreservation: KeyPreservation::DISCARD); ``` +
+ +## Evaluation strategies + +The complexity of every operation in this library is determined by the evaluation strategy chosen at creation time. +Calling `createFrom`, `createFromEmpty`, or `createFromClosure` produces a collection backed by an `EagerPipeline`. +Calling `createLazyFrom`, `createLazyFromEmpty`, or `createLazyFromClosure` produces a collection backed by a +`LazyPipeline`. All subsequent operations on that collection inherit the behavior of the chosen pipeline. + +This is analogous to how `java.util.ArrayList` and `java.util.LinkedList` both implement `java.util.List`, but each +operation has different costs depending on which concrete class backs the list. + +### Eager pipeline + +When the collection is created eagerly, elements are stored in a plain PHP array. This array is the source of truth +for all operations. + +**Creation.** Factory methods like `createFrom` call `iterator_to_array` on the input, consuming all elements +immediately. Time: O(n). Space: O(n). + +**Transforming operations.** Every call to a transforming method (`add`, `filter`, `map`, `sort`, etc.) calls +`pipe()` internally, which executes `iterator_to_array($operation->apply($this->elements))`. This means the +operation is applied to all elements immediately and the result is stored in a new array. The time cost depends +on the operation (O(n) for filter, O(n log n) for sort), and the space cost is always O(n) because a new array +is allocated. + +**Access operations.** Methods like `count`, `first`, `last`, and `getBy` read the internal array directly. +`count` calls PHP's native `count()` on the array. `first` and `last` use `array_key_first` and `array_key_last`. +`getBy` uses `array_key_exists`. All are O(1) time and O(1) space. + +**Terminal operations.** Methods like `contains`, `reduce`, `each`, `equals`, and `findBy` iterate over the +collection. Since the elements are already materialized, the iteration itself is O(n). No additional +materialization cost is incurred. + +### Lazy pipeline + +When the collection is created lazily, nothing is computed at creation time. The source (iterable or closure) is +stored by reference, and operations are accumulated as stages in an array. + +**Creation.** Factory methods like `createLazyFrom` store a reference to the iterable. `createLazyFromClosure` +stores the closure without invoking it. Time: O(1). Space: O(1). + +**Transforming operations.** Every call to a transforming method calls `pipe()`, which appends the operation to +the internal `$stages` array. No elements are processed. Time: O(1). Space: O(1). The actual cost is deferred +to the moment the collection is consumed. + +**Consumption.** When the collection is iterated (explicitly or through `count`, `toArray`, `reduce`, etc.), +`process()` is called. It invokes the source closure (if applicable), then chains all stages into a generator +pipeline. Elements flow one at a time through every stage: each element passes through stage 0, then stage 1, +then stage 2, and so on, before the next element enters the pipeline. For k streaming stages, total time is +O(n * k). + +**Access operations.** `count` calls `iterator_count`, which consumes the entire generator: O(n). `first` and +`isEmpty` yield one element from the generator: O(1). `last` and `getBy` iterate the generator: O(n) worst case. + +**Barrier operations.** Most operations are streaming: they process one element at a time without accumulating +state. Two operations are exceptions. `sort` must consume all input (via `iterator_to_array`), sort it, then +yield the sorted result: O(n log n) time, O(n) space. `groupBy` must accumulate all elements into a groups +array, then yield: O(n) time, O(n) space. When a barrier exists in a lazy pipeline, it forces full evaluation +of all preceding stages before any subsequent stage can process an element. This means that calling `first()` +on a lazy collection that has a `sort()` in its pipeline still costs O(n log n), because the sort barrier must +consume everything first. + +### Complexity reference + +The table below summarizes the time and space complexity of each method under both strategies. Each value was +derived by tracing the execution path from `Collection` through the `Pipeline` into the underlying `Operation`. +The column "Why" references the pipeline behavior described above. + +#### Factory methods + +| Method | Time | Space | Why | +|-------------------------|------|-------|------------------------------------------------------| +| `createFrom` | O(n) | O(n) | Calls `iterator_to_array` on the input. | +| `createFromEmpty` | O(1) | O(1) | Creates an empty array. | +| `createFromClosure` | O(n) | O(n) | Invokes the closure, then calls `iterator_to_array`. | +| `createLazyFrom` | O(1) | O(1) | Stores the iterable reference without iterating. | +| `createLazyFromEmpty` | O(1) | O(1) | Stores an empty array reference. | +| `createLazyFromClosure` | O(1) | O(1) | Stores the closure without invoking it. | + +#### Transforming methods + +For lazy collections, all transforming methods are O(1) time and O(1) space at call time because `pipe()` only +appends a stage. The cost shown below is for eager collections, where `pipe()` materializes immediately. + +| Method | Time | Space | Why | +|-------------|------------|----------|------------------------------------------------------------------------------------------| +| `add` | O(n + m) | O(n + m) | Yields all existing elements, then the m new ones. | +| `merge` | O(n + m) | O(n + m) | Yields all elements from both collections. | +| `filter` | O(n) | O(n) | Tests each element against the predicate. | +| `map` | O(n * t) | O(n) | Applies t transformations to each element. | +| `flatten` | O(n + s) | O(n + s) | Iterates each element; expands nested iterables by one level. s = total nested elements. | +| `remove` | O(n) | O(n) | Tests each element for equality. | +| `removeAll` | O(n) | O(n) | Tests each element against the predicate. | +| `sort` | O(n log n) | O(n) | Materializes all elements, sorts via `uasort` or `ksort`, then yields. Barrier. | +| `slice` | O(n) | O(n) | Iterates up to offset + length elements. | +| `groupBy` | O(n) | O(n) | Accumulates all elements into a groups array, then yields. Barrier. | + +#### Access methods + +These delegate directly to the pipeline. The cost differs between eager and lazy because eager reads the +internal array, while lazy must evaluate the generator. + +| Method | Eager | Lazy | Why | +|-----------|-------|------|------------------------------------------------------------------------| +| `count` | O(1) | O(n) | Eager: `count($array)`. Lazy: `iterator_count($generator)`. | +| `first` | O(1) | O(1) | Eager: `array_key_first`. Lazy: first yield from the generator. | +| `last` | O(1) | O(n) | Eager: `array_key_last`. Lazy: iterates all to reach the last element. | +| `getBy` | O(1) | O(n) | Eager: `array_key_exists`. Lazy: iterates until the index. | +| `isEmpty` | O(1) | O(1) | Checks if the first element exists. | + +#### Terminal methods + +These iterate the collection to produce a result. Since eager collections already hold a materialized array, the +iteration cost is the same for both strategies. + +| Method | Time | Space | Why | +|----------------|----------|-------|-----------------------------------------------------------------| +| `contains` | O(n) | O(1) | Iterates until the element is found or the end is reached. | +| `findBy` | O(n * p) | O(1) | Tests p predicates per element until a match. | +| `each` | O(n * a) | O(1) | Applies a actions to every element. | +| `equals` | O(n) | O(1) | Walks two generators in parallel, comparing element by element. | +| `reduce` | O(n) | O(1) | Folds all elements into a single carry value. | +| `joinToString` | O(n) | O(n) | Accumulates into an intermediate array, then calls `implode`. | +| `toArray` | O(n) | O(n) | Iterates all elements into a new array. | +| `toJson` | O(n) | O(n) | Calls `toArray`, then `json_encode`. | +
## FAQ @@ -434,13 +566,12 @@ 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. +- **Eager evaluation** (`createFrom` / `createFromEmpty` / `createFromClosure`): Elements are materialized immediately + into an array, enabling constant-time access by index, count, first, last, and repeated iteration. - **Lazy evaluation** (`createLazyFrom` / `createLazyFromEmpty` / `createLazyFromClosure`): Elements are processed - on-demand through generators, - consuming memory only as each element is yielded. Ideal for large datasets or pipelines where not all elements need to - be materialized. + on-demand through generators, consuming memory only as each element is yielded. Ideal for large datasets or pipelines + where not all elements need to be materialized.
diff --git a/src/Collectible.php b/src/Collectible.php index 1b09305..6fab550 100644 --- a/src/Collectible.php +++ b/src/Collectible.php @@ -16,8 +16,8 @@ * * Two evaluation strategies are available: * - * - createFrom / createFromEmpty — eager evaluation, materialized immediately. - * - createLazyFrom / createLazyFromEmpty — lazy evaluation via generators, on-demand. + * - createFrom / createFromEmpty / createFromClosure: eager evaluation, materialized immediately. + * - createLazyFrom / createLazyFromEmpty / createLazyFromClosure: lazy evaluation via generators, on-demand. */ interface Collectible extends Countable, IteratorAggregate { @@ -27,6 +27,8 @@ interface Collectible extends Countable, IteratorAggregate * Elements are materialized immediately into an array, enabling * constant-time access by index, count, and repeated iteration. * + * O(n) time, O(n) space. Iterates the input once and stores all elements. + * * @param iterable $elements The elements to populate the collection with. * @return static A new collection containing the given elements. */ @@ -35,16 +37,34 @@ public static function createFrom(iterable $elements): static; /** * Creates an empty collection using eager evaluation. * + * O(1) time, O(1) space. + * * @return static An empty collection. */ public static function createFromEmpty(): static; + /** + * Creates a collection using eager evaluation from a closure that produces an iterable. + * + * The closure is invoked once at creation time and its result is materialized + * immediately into an array, enabling constant-time access by index, count, + * and repeated iteration. + * + * O(n) time, O(n) space. Invokes the closure and stores all yielded elements. + * + * @param Closure $factory A closure returning an iterable of elements. + * @return static A new collection backed by the materialized closure result. + */ + public static function createFromClosure(Closure $factory): static; + /** * Creates a collection populated with the given elements using lazy evaluation. * * Elements are processed on-demand through generators, consuming * memory only as each element is yielded. * + * O(1) time, O(1) space. Stores a reference to the iterable without iterating. + * * @param iterable $elements The elements to populate the collection with. * @return static A new collection containing the given elements. */ @@ -53,6 +73,8 @@ public static function createLazyFrom(iterable $elements): static; /** * Creates an empty collection using lazy evaluation. * + * O(1) time, O(1) space. + * * @return static An empty collection. */ public static function createLazyFromEmpty(): static; @@ -63,6 +85,8 @@ public static function createLazyFromEmpty(): static; * The closure is invoked each time the collection is iterated, enabling * safe re-iteration over generators or other single-use iterables. * + * O(1) time, O(1) space. Stores the closure without invoking it. + * * @param Closure $factory A closure returning an iterable of elements. * @return static A new collection backed by the given factory. */ @@ -71,6 +95,9 @@ public static function createLazyFromClosure(Closure $factory): static; /** * Returns a new collection with the specified elements appended. * + * Eager: O(n + m) time, O(n + m) space. Materializes all existing and new elements. + * Lazy: O(1) time, O(1) space. Appends a pipeline stage without iterating. + * * @param mixed ...$elements The elements to append. * @return static A new collection with the additional elements. */ @@ -79,6 +106,9 @@ public function add(mixed ...$elements): static; /** * Merges the elements of another Collectible into the current Collection. * + * Eager: O(n + m) time, O(n + m) space. Materializes elements from both collections. + * Lazy: O(1) time, O(1) space. Appends a pipeline stage without iterating. + * * @param Collectible $other The collection to merge with. * @return static A new collection containing elements from both collections. */ @@ -89,6 +119,8 @@ public function merge(Collectible $other): static; * * Uses strict equality for scalars and loose equality for objects. * + * O(n) time, O(1) space. Iterates until the element is found or the end is reached. + * * @param mixed $element The element to search for. * @return bool True if the element exists, false otherwise. */ @@ -97,6 +129,9 @@ public function contains(mixed $element): bool; /** * Returns the total number of elements. * + * Eager: O(1) time, O(1) space. Reads the array length directly. + * Lazy: O(n) time, O(1) space. Must iterate all elements to count. + * * @return int The element count. */ public function count(): int; @@ -105,6 +140,8 @@ public function count(): int; * Finds the first element that satisfies any given predicate. * Without predicates, returns null. * + * O(n * p) time, O(1) space. Iterates until a match is found. p = number of predicates. + * * @param Closure ...$predicates Conditions to test each element against. * @return mixed The first matching element or null if no match is found. */ @@ -115,6 +152,8 @@ public function findBy(Closure ...$predicates): mixed; * * This is a terminal operation. The collection is not returned. * + * O(n * a) time, O(1) space. Iterates all elements. a = number of actions. + * * @param Closure ...$actions Actions to perform on each element. */ public function each(Closure ...$actions): void; @@ -125,6 +164,8 @@ public function each(Closure ...$actions): void; * Two collections are equal when they have the same size and every * pair at the same position satisfies the equality comparison. * + * O(n) time, O(1) space. Walks both collections in parallel, comparing element by element. + * * @param Collectible $other The collection to compare against. * @return bool True if both collections are element-wise equal. */ @@ -135,6 +176,9 @@ public function equals(Collectible $other): bool; * * All occurrences of the element are removed. * + * Eager: O(n) time, O(n) space. Materializes a new array excluding matches. + * Lazy: O(1) time, O(1) space. Appends a pipeline stage without iterating. + * * @param mixed $element The element to remove. * @return static A new collection without the specified element. */ @@ -144,6 +188,9 @@ public function remove(mixed $element): static; * Returns a new collection with all elements removed that satisfy the given predicate. * When no predicate is provided (i.e., $predicate is null), all elements are removed. * + * Eager: O(n) time, O(n) space. Materializes a new array excluding matches. + * Lazy: O(1) time, O(1) space. Appends a pipeline stage without iterating. + * * @param Closure|null $predicate Condition to determine which elements to remove. * @return static A new collection with the matching elements removed. */ @@ -154,6 +201,9 @@ public function removeAll(?Closure $predicate = null): static; * * Without predicates, falsy values are removed. * + * Eager: O(n) time, O(n) space. Materializes a new array with matching elements. + * Lazy: O(1) time, O(1) space. Appends a pipeline stage without iterating. + * * @param Closure|null ...$predicates Conditions each element must meet. * @return static A new collection with only the matching elements. */ @@ -162,6 +212,9 @@ public function filter(?Closure ...$predicates): static; /** * Returns the first element, or a default if the collection is empty. * + * Eager: O(1) time, O(1) space. Direct array access via array_key_first. + * Lazy: O(1) time, O(1) space. Yields once from the pipeline. + * * @param mixed $defaultValueIfNotFound Value returned when the collection is empty. * @return mixed The first element or the default. */ @@ -170,6 +223,9 @@ public function first(mixed $defaultValueIfNotFound = null): mixed; /** * Flattens nested iterables by exactly one level. Non-iterable elements are yielded as-is. * + * Eager: O(n + s) time, O(n + s) space. s = total nested elements across all iterables. + * Lazy: O(1) time, O(1) space. Appends a pipeline stage without iterating. + * * @return static A new collection with elements flattened by one level. */ public function flatten(): static; @@ -177,6 +233,9 @@ public function flatten(): static; /** * Returns the element at the given zero-based index. * + * Eager: O(1) time, O(1) space. Direct array access via array_key_exists. + * Lazy: O(n) time, O(1) space. Iterates until the index is reached. + * * @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. @@ -189,6 +248,9 @@ public function getBy(int $index, mixed $defaultValueIfNotFound = null): mixed; * The classifier receives each element and must return the group key. * The resulting collection contains key to element-list pairs. * + * Eager: O(n) time, O(n) space. Materializes all groups into an associative array. + * Lazy: O(1) time, O(1) space. Appends a pipeline stage without iterating. + * * @param Closure $classifier Maps each element to its group key. * @return static A new collection of grouped elements. */ @@ -197,6 +259,9 @@ public function groupBy(Closure $classifier): static; /** * Determines whether the collection has no elements. * + * Eager: O(1) time, O(1) space. Checks the first yield from the materialized array. + * Lazy: O(1) time, O(1) space. Yields once from the pipeline. + * * @return bool True if the collection is empty. */ public function isEmpty(): bool; @@ -204,6 +269,8 @@ public function isEmpty(): bool; /** * Joins all elements into a string with the given separator. * + * O(n) time, O(n) space. Accumulates all elements into an intermediate array, then implodes. + * * @param string $separator The delimiter placed between each element. * @return string The concatenated result. */ @@ -212,6 +279,9 @@ public function joinToString(string $separator): string; /** * Returns the last element, or a default if the collection is empty. * + * Eager: O(1) time, O(1) space. Direct array access via array_key_last. + * Lazy: O(n) time, O(1) space. Must iterate all elements to find the last. + * * @param mixed $defaultValueIfNotFound Value returned when the collection is empty. * @return mixed The last element or the default. */ @@ -222,6 +292,9 @@ public function last(mixed $defaultValueIfNotFound = null): mixed; * * Transformations are applied in order. Each receives the current value and key. * + * Eager: O(n * t) time, O(n) space. Materializes all transformed elements. t = number of transformations. + * Lazy: O(1) time, O(1) space. Appends a pipeline stage without iterating. + * * @param Closure ...$transformations Functions applied to each element. * @return static A new collection with the transformed elements. */ @@ -232,6 +305,8 @@ public function map(Closure ...$transformations): static; * * The accumulator receives the carry and the current element. * + * O(n) time, O(1) space. Iterates all elements, maintaining a single carry value. + * * @param Closure $accumulator Combines the carry with each element. * @param mixed $initial The starting value for the accumulation. * @return mixed The final accumulated result. @@ -243,6 +318,9 @@ public function reduce(Closure $accumulator, mixed $initial): mixed; * * Without a comparator, the spaceship operator is used. * + * Eager: O(n log n) time, O(n) space. Materializes and sorts all elements. + * Lazy: O(1) time, O(1) space. Appends a pipeline stage without iterating. + * * @param Order $order The sorting direction. * @param Closure|null $comparator Custom comparison function. * @return static A new sorted collection. @@ -252,6 +330,9 @@ public function sort(Order $order = Order::ASCENDING_KEY, ?Closure $comparator = /** * Extracts a contiguous segment of the collection. * + * Eager: O(n) time, O(n) space. Materializes the segment into a new array. + * Lazy: O(1) time, O(1) space. Appends a pipeline stage without iterating. + * * @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. @@ -261,6 +342,8 @@ public function slice(int $offset, int $length = -1): static; /** * Converts the Collection to an array. * + * O(n) time, O(n) space. Iterates all elements and stores them in an array. + * * The key preservation behavior should be provided from the `KeyPreservation` enum: * - {@see KeyPreservation::PRESERVE}: Preserves the array keys. * - {@see KeyPreservation::DISCARD}: Discards the array keys. @@ -275,6 +358,8 @@ public function toArray(KeyPreservation $keyPreservation = KeyPreservation::PRES /** * Converts the Collection to a JSON string. * + * O(n) time, O(n) space. Converts to array, then encodes to JSON. + * * The key preservation behavior should be provided from the `KeyPreservation` enum: * - {@see KeyPreservation::PRESERVE}: Preserves the array keys. * - {@see KeyPreservation::DISCARD}: Discards the array keys. diff --git a/src/Collection.php b/src/Collection.php index 1f0e90d..bdad92d 100644 --- a/src/Collection.php +++ b/src/Collection.php @@ -10,9 +10,7 @@ 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\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; @@ -47,6 +45,11 @@ public static function createFromEmpty(): static return static::createFrom(elements: []); } + public static function createFromClosure(Closure $factory): static + { + return new static(pipeline: EagerPipeline::fromClosure(factory: $factory)); + } + public static function createLazyFrom(iterable $elements): static { return new static(pipeline: LazyPipeline::from(source: $elements)); @@ -119,7 +122,7 @@ public function filter(?Closure ...$predicates): static public function first(mixed $defaultValueIfNotFound = null): mixed { - return First::from(elements: $this, defaultValueIfNotFound: $defaultValueIfNotFound); + return $this->pipeline->first(defaultValueIfNotFound: $defaultValueIfNotFound); } public function flatten(): static @@ -139,7 +142,7 @@ public function groupBy(Closure $classifier): static public function isEmpty(): bool { - return First::isAbsent(elements: $this); + return $this->pipeline->isEmpty(); } public function joinToString(string $separator): string @@ -149,7 +152,7 @@ public function joinToString(string $separator): string public function last(mixed $defaultValueIfNotFound = null): mixed { - return Last::from(elements: $this, defaultValueIfNotFound: $defaultValueIfNotFound); + return $this->pipeline->last(defaultValueIfNotFound: $defaultValueIfNotFound); } public function map(Closure ...$transformations): static diff --git a/src/Internal/EagerPipeline.php b/src/Internal/EagerPipeline.php index 8cbb9c1..5360361 100644 --- a/src/Internal/EagerPipeline.php +++ b/src/Internal/EagerPipeline.php @@ -4,6 +4,7 @@ namespace TinyBlocks\Collection\Internal; +use Closure; use Generator; use TinyBlocks\Collection\Internal\Operations\Operation; @@ -20,6 +21,13 @@ public static function from(iterable $source): EagerPipeline return new EagerPipeline(elements: $elements); } + public static function fromClosure(Closure $factory): EagerPipeline + { + $elements = iterator_to_array($factory()); + + return new EagerPipeline(elements: $elements); + } + public function pipe(Operation $operation): Pipeline { $elements = iterator_to_array($operation->apply(elements: $this->elements)); @@ -32,6 +40,25 @@ public function count(): int return count($this->elements); } + public function first(mixed $defaultValueIfNotFound = null): mixed + { + return empty($this->elements) + ? $defaultValueIfNotFound + : $this->elements[array_key_first($this->elements)]; + } + + public function isEmpty(): bool + { + return empty($this->elements); + } + + public function last(mixed $defaultValueIfNotFound = null): mixed + { + return empty($this->elements) + ? $defaultValueIfNotFound + : $this->elements[array_key_last($this->elements)]; + } + public function getBy(int $index, mixed $defaultValueIfNotFound = null): mixed { return array_key_exists($index, $this->elements) diff --git a/src/Internal/LazyPipeline.php b/src/Internal/LazyPipeline.php index 0724a87..bbce33e 100644 --- a/src/Internal/LazyPipeline.php +++ b/src/Internal/LazyPipeline.php @@ -41,6 +41,31 @@ public function count(): int return iterator_count($this->process()); } + public function first(mixed $defaultValueIfNotFound = null): mixed + { + foreach ($this->process() as $element) { + return $element; + } + + return $defaultValueIfNotFound; + } + + public function isEmpty(): bool + { + return !$this->process()->valid(); + } + + public function last(mixed $defaultValueIfNotFound = null): mixed + { + $last = $defaultValueIfNotFound; + + foreach ($this->process() as $element) { + $last = $element; + } + + return $last; + } + public function getBy(int $index, mixed $defaultValueIfNotFound = null): mixed { foreach ($this->process() as $currentIndex => $value) { diff --git a/src/Internal/Operations/Resolving/First.php b/src/Internal/Operations/Resolving/First.php deleted file mode 100644 index 94fc0ee..0000000 --- a/src/Internal/Operations/Resolving/First.php +++ /dev/null @@ -1,26 +0,0 @@ - $value) { - if ($this->predicate === null || ($this->predicate)($value)) { + if (is_null($this->predicate) || ($this->predicate)($value)) { continue; } diff --git a/src/Internal/Pipeline.php b/src/Internal/Pipeline.php index 5f15edb..c607625 100644 --- a/src/Internal/Pipeline.php +++ b/src/Internal/Pipeline.php @@ -30,16 +30,50 @@ 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. + * Eager: O(1) time, O(1) space. Direct array count. + * Lazy: O(n) time, O(1) space. Must iterate all elements. * * @return int The element count. */ public function count(): int; + /** + * Returns the first element, or a default if empty. + * + * Eager: O(1) time, O(1) space. Direct array access via array_key_first. + * Lazy: O(1) time, O(1) space. Yields once from the pipeline. + * + * @param mixed $defaultValueIfNotFound Value returned when empty. + * @return mixed The first element or the default. + */ + public function first(mixed $defaultValueIfNotFound = null): mixed; + + /** + * Determines whether the pipeline has no elements. + * + * Eager: O(1) time, O(1) space. Checks if the array is empty. + * Lazy: O(1) time, O(1) space. Checks if the generator produces a value. + * + * @return bool True if the pipeline is empty. + */ + public function isEmpty(): bool; + + /** + * Returns the last element, or a default if empty. + * + * Eager: O(1) time, O(1) space. Direct array access via array_key_last. + * Lazy: O(n) time, O(1) space. Must iterate all elements. + * + * @param mixed $defaultValueIfNotFound Value returned when empty. + * @return mixed The last element or the default. + */ + public function last(mixed $defaultValueIfNotFound = null): mixed; + /** * Returns the element at the given zero-based index. * - * Eager pipelines provide this in O(1), lazy pipelines must iterate. + * Eager: O(1) time, O(1) space. Direct array access via array_key_exists. + * Lazy: O(n) time, O(1) space. Must iterate up to the index. * * @param int $index The zero-based position. * @param mixed $defaultValueIfNotFound Value returned when the index is out of bounds. diff --git a/tests/CollectionTest.php b/tests/CollectionTest.php index df7d043..20ca6b7 100644 --- a/tests/CollectionTest.php +++ b/tests/CollectionTest.php @@ -258,6 +258,7 @@ public function testInvoicesFilterByCustomer(): void $aliceInvoices = $invoices->forCustomer(customer: 'Alice'); /** @Then the result should still be an instance of Invoices */ + /** @noinspection PhpConditionAlreadyCheckedInspection */ self::assertInstanceOf(Invoices::class, $aliceInvoices); /** @And Alice should have two invoices */ @@ -617,8 +618,17 @@ public function testClosureAndLazyAndEagerProduceSameResults(): void /** @And a map transformation that multiplies by 10 */ $map = static fn(int $value): int => $value * 10; - /** @When applying filter, map and sort on a closure-backed collection */ - $closureResult = Collection::createLazyFromClosure(factory: static function () use ($elements): array { + /** @When applying filter, map and sort on a lazy closure-backed collection */ + $lazyClosureResult = Collection::createLazyFromClosure(factory: static function () use ($elements): array { + return $elements; + }) + ->filter(predicates: $filter) + ->map(transformations: $map) + ->sort(order: SortOrder::ASCENDING_VALUE) + ->toArray(keyPreservation: KeyPreservation::DISCARD); + + /** @And applying the same operations on an eager closure-backed collection */ + $eagerClosureResult = Collection::createFromClosure(factory: static function () use ($elements): array { return $elements; }) ->filter(predicates: $filter) @@ -640,14 +650,15 @@ public function testClosureAndLazyAndEagerProduceSameResults(): void ->sort(order: SortOrder::ASCENDING_VALUE) ->toArray(keyPreservation: KeyPreservation::DISCARD); - /** @Then all three should produce identical arrays */ - self::assertSame($closureResult, $lazyResult); - self::assertSame($closureResult, $eagerResult); + /** @Then all four should produce identical arrays */ + self::assertSame($lazyClosureResult, $eagerClosureResult); + self::assertSame($lazyClosureResult, $lazyResult); + self::assertSame($lazyClosureResult, $eagerResult); } - public function testClosureBackedCarriersPreservesType(): void + public function testLazyClosureBackedCarriersPreservesType(): void { - /** @Given a closure-backed Carriers collection */ + /** @Given a lazy closure-backed Carriers collection */ $carriers = Carriers::createLazyFromClosure(factory: static function (): array { return ['dhl', 'fedex', 'ups']; }); @@ -663,4 +674,23 @@ public function testClosureBackedCarriersPreservesType(): void /** @And the carriers should be uppercased */ self::assertSame(['DHL', 'FEDEX', 'UPS'], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); } + + public function testEagerClosureBackedCarriersPreservesType(): void + { + /** @Given an eager closure-backed Carriers collection */ + $carriers = Carriers::createFromClosure(factory: static function (): array { + return ['dhl', 'fedex', 'ups']; + }); + + /** @When mapping to uppercase */ + $actual = $carriers->map( + transformations: static fn(string $name): string => strtoupper($name) + ); + + /** @Then the result should still be an instance of Carriers */ + self::assertInstanceOf(Carriers::class, $actual); + + /** @And the carriers should be uppercased */ + self::assertSame(['DHL', 'FEDEX', 'UPS'], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + } } diff --git a/tests/EagerCollectionTest.php b/tests/EagerCollectionTest.php index 40f7079..d0f32f5 100644 --- a/tests/EagerCollectionTest.php +++ b/tests/EagerCollectionTest.php @@ -8,6 +8,10 @@ use PHPUnit\Framework\TestCase; use stdClass; use Test\TinyBlocks\Collection\Models\Amount; +use Test\TinyBlocks\Collection\Models\Carriers; +use Test\TinyBlocks\Collection\Models\Shipment; +use Test\TinyBlocks\Collection\Models\ShipmentRecord; +use Test\TinyBlocks\Collection\Models\Shipments; use TinyBlocks\Collection\Collection; use TinyBlocks\Collection\Order; use TinyBlocks\Currency\Currency; @@ -1061,4 +1065,329 @@ public function testChainedOperationsWithIntegers(): void /** @Then the sum should be 171700 */ self::assertSame(171700, $sum); } + + public function testFromClosure(): void + { + /** @Given a closure that returns three elements */ + $factory = static function (): array { + return [1, 2, 3]; + }; + + /** @When creating an eager collection from the closure */ + $collection = Collection::createFromClosure(factory: $factory); + + /** @Then the collection should contain all three elements */ + self::assertSame(3, $collection->count()); + + /** @And the array should match the expected elements */ + self::assertSame([1, 2, 3], $collection->toArray()); + } + + public function testFromClosureReiteratesSuccessfully(): void + { + /** @Given a closure-backed eager collection */ + $collection = Collection::createFromClosure(factory: static function (): array { + return [10, 20, 30]; + }); + + /** @When consuming the collection via count */ + $count = $collection->count(); + + /** @Then the count should be 3 */ + self::assertSame(3, $count); + + /** @And a subsequent toArray should still return all elements */ + self::assertSame([10, 20, 30], $collection->toArray()); + + /** @And first should return the first element */ + self::assertSame(10, $collection->first()); + + /** @And last should return the last element */ + self::assertSame(30, $collection->last()); + } + + public function testFromClosureWithEmptyClosure(): void + { + /** @Given a closure that returns an empty array */ + $collection = Collection::createFromClosure(factory: static function (): array { + return []; + }); + + /** @When checking the collection */ + $isEmpty = $collection->isEmpty(); + + /** @Then the collection should be empty */ + self::assertTrue($isEmpty); + + /** @And the count should be zero */ + self::assertSame(0, $collection->count()); + } + + public function testFromClosureWithChainedOperations(): void + { + /** @Given a closure-backed eager collection with integers */ + $collection = Collection::createFromClosure(factory: static function (): array { + return [5, 3, 1, 4, 2]; + }); + + /** @When chaining filter, map and sort */ + $actual = $collection + ->filter(predicates: static fn(int $value): bool => $value > 2) + ->map(transformations: static fn(int $value): int => $value * 10) + ->sort(order: Order::ASCENDING_VALUE); + + /** @Then the result should contain the filtered, mapped and sorted values */ + self::assertSame([30, 40, 50], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + } + + public function testFromClosureWithObjects(): void + { + /** @Given a closure that returns Amount objects */ + $collection = Collection::createFromClosure(factory: static function (): array { + return [ + new Amount(value: 100.00, currency: Currency::USD), + new Amount(value: 200.00, currency: Currency::USD), + new Amount(value: 300.00, currency: Currency::USD), + ]; + }); + + /** @When reducing to sum all amounts */ + $total = $collection->reduce( + accumulator: static fn(float $carry, Amount $amount): float => $carry + $amount->value, + initial: 0.0 + ); + + /** @Then the total should be 600 */ + self::assertSame(600.00, $total); + } + + public function testFromClosureGetByIndex(): void + { + /** @Given a closure-backed eager collection */ + $collection = Collection::createFromClosure(factory: static function (): array { + return ['alpha', 'beta', 'gamma']; + }); + + /** @When retrieving element at index 1 */ + $actual = $collection->getBy(index: 1); + + /** @Then it should return the second element */ + self::assertSame('beta', $actual); + } + + public function testFromClosureContainsElement(): void + { + /** @Given a closure-backed eager collection */ + $collection = Collection::createFromClosure(factory: static function (): array { + return ['alpha', 'beta', 'gamma']; + }); + + /** @When checking if the collection contains an existing element */ + $containsBeta = $collection->contains(element: 'beta'); + + /** @Then it should return true */ + self::assertTrue($containsBeta); + + /** @And checking for a non-existing element should return false */ + self::assertFalse($collection->contains(element: 'delta')); + } + + public function testFromClosureAdd(): void + { + /** @Given a closure-backed eager collection */ + $collection = Collection::createFromClosure(factory: static function (): array { + return [1, 2]; + }); + + /** @When adding elements */ + $actual = $collection->add(3, 4); + + /** @Then all elements should be present */ + self::assertSame([1, 2, 3, 4], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + } + + public function testFromClosureMerge(): void + { + /** @Given a closure-backed eager collection */ + $closureCollection = Collection::createFromClosure(factory: static function (): array { + return [1, 2]; + }); + + /** @And an eager collection */ + $eagerCollection = Collection::createFrom(elements: [3, 4]); + + /** @When merging them */ + $actual = $closureCollection->merge(other: $eagerCollection); + + /** @Then the result should contain all elements */ + self::assertSame([1, 2, 3, 4], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + } + + public function testFromClosureRecordMapsToTypedCollection(): void + { + /** @Given raw shipment records as arrays */ + $records = [ + [ + 'id' => 'SHP-001', + 'status' => 'shipped', + 'carrier' => 'DHL', + 'created_at' => '2026-01-10T08:00:00+00:00', + 'customer_id' => 'C-100' + ], + [ + 'id' => 'SHP-002', + 'status' => 'pending', + 'carrier' => 'FedEx', + 'created_at' => '2026-01-11T09:00:00+00:00', + 'customer_id' => 'C-200' + ], + [ + 'id' => 'SHP-003', + 'status' => 'shipped', + 'carrier' => 'UPS', + 'created_at' => '2026-01-12T10:00:00+00:00', + 'customer_id' => 'C-100' + ], + ]; + + /** @When mapping records to a typed Shipments collection via ShipmentRecord */ + $shipments = ShipmentRecord::fromRecords(records: $records)->toShipments(); + + /** @Then the collection should contain three shipments */ + self::assertSame(3, $shipments->count()); + + /** @And the collection should be an instance of Shipments */ + self::assertInstanceOf(Shipments::class, $shipments); + } + + public function testFromClosureMapsRecordsToShipments(): void + { + /** @Given raw shipment records as arrays */ + $records = [ + [ + 'id' => 'SHP-001', + 'status' => 'shipped', + 'carrier' => 'DHL', + 'created_at' => '2026-01-10T08:00:00+00:00', + 'customer_id' => 'C-100' + ], + [ + 'id' => 'SHP-002', + 'status' => 'pending', + 'carrier' => 'FedEx', + 'created_at' => '2026-01-11T09:00:00+00:00', + 'customer_id' => 'C-200' + ], + ]; + + /** @When creating a Shipments collection from a closure that maps records */ + $shipments = Shipments::createFromClosure( + factory: static function () use ($records): Collection { + return Collection::createFrom(elements: $records) + ->map(transformations: static fn(array $record): Shipment => Shipment::from( + id: $record['id'], + status: $record['status'], + carrier: $record['carrier'], + createdAt: $record['created_at'], + customerId: $record['customer_id'] + )); + } + ); + + /** @Then the collection should contain two shipments */ + self::assertSame(2, $shipments->count()); + + /** @And the first shipment should have the expected id */ + self::assertSame('SHP-001', $shipments->first()->id); + } + + public function testFromClosureShipmentsSerializesToArray(): void + { + /** @Given raw shipment records as arrays */ + $records = [ + [ + 'id' => 'SHP-001', + 'status' => 'shipped', + 'carrier' => 'DHL', + 'created_at' => '2026-01-10T08:00:00+00:00', + 'customer_id' => 'C-100' + ], + ]; + + /** @When mapping records via ShipmentRecord and converting to array */ + $actual = ShipmentRecord::fromRecords(records: $records)->toShipments()->toArray(); + + /** @Then the serialized array should match the original record structure */ + self::assertSame([ + [ + 'id' => 'SHP-001', + 'status' => 'shipped', + 'carrier' => 'DHL', + 'created_at' => '2026-01-10T08:00:00+00:00', + 'customer_id' => 'C-100', + ], + ], $actual); + } + + public function testFromClosurePreservesTypedCollectionInstance(): void + { + /** @Given a closure-backed Carriers collection */ + $carriers = Carriers::createFromClosure(factory: static function (): array { + return ['dhl', 'fedex', 'ups']; + }); + + /** @When mapping to uppercase */ + $actual = $carriers->map( + transformations: static fn(string $name): string => strtoupper($name) + ); + + /** @Then the result should still be an instance of Carriers */ + self::assertInstanceOf(Carriers::class, $actual); + + /** @And the carriers should be uppercased */ + self::assertSame(['DHL', 'FEDEX', 'UPS'], $actual->toArray(keyPreservation: KeyPreservation::DISCARD)); + } + + public function testCreateFromAndCreateFromClosureProduceSameShipments(): void + { + /** @Given raw shipment records as arrays */ + $records = [ + [ + 'id' => 'SHP-001', + 'status' => 'shipped', + 'carrier' => 'DHL', + 'created_at' => '2026-01-10T08:00:00+00:00', + 'customer_id' => 'C-100' + ], + [ + 'id' => 'SHP-002', + 'status' => 'pending', + 'carrier' => 'FedEx', + 'created_at' => '2026-01-11T09:00:00+00:00', + 'customer_id' => 'C-200' + ], + [ + 'id' => 'SHP-003', + 'status' => 'shipped', + 'carrier' => 'UPS', + 'created_at' => '2026-01-12T10:00:00+00:00', + 'customer_id' => 'C-100' + ], + ]; + + /** @And a ShipmentRecord built from those records */ + $shipmentRecord = ShipmentRecord::fromRecords(records: $records); + + /** @When creating shipments via createFrom */ + $fromCreateFrom = $shipmentRecord->toShipments(); + + /** @And creating shipments via createFromClosure */ + $fromCreateFromClosure = $shipmentRecord->toShipmentsFromClosure(); + + /** @Then both should produce identical arrays */ + self::assertSame($fromCreateFrom->toArray(), $fromCreateFromClosure->toArray()); + + /** @And both should have the same count */ + self::assertSame($fromCreateFrom->count(), $fromCreateFromClosure->count()); + } } diff --git a/tests/Models/Shipment.php b/tests/Models/Shipment.php new file mode 100644 index 0000000..7430a05 --- /dev/null +++ b/tests/Models/Shipment.php @@ -0,0 +1,44 @@ + $this->id, + 'status' => $this->status, + 'carrier' => $this->carrier, + 'created_at' => $this->createdAt, + 'customer_id' => $this->customerId + ]; + } +} diff --git a/tests/Models/ShipmentRecord.php b/tests/Models/ShipmentRecord.php new file mode 100644 index 0000000..2792443 --- /dev/null +++ b/tests/Models/ShipmentRecord.php @@ -0,0 +1,47 @@ +records) + ->map(transformations: static fn(array $record): Shipment => Shipment::from( + id: $record['id'], + status: $record['status'], + carrier: $record['carrier'], + createdAt: $record['created_at'], + customerId: $record['customer_id'] + )) + ); + } + + public function toShipmentsFromClosure(): Shipments + { + return Shipments::createFromClosure( + factory: fn(): Collection => Collection::createFrom(elements: $this->records) + ->map(transformations: static fn(array $record): Shipment => Shipment::from( + id: $record['id'], + status: $record['status'], + carrier: $record['carrier'], + createdAt: $record['created_at'], + customerId: $record['customer_id'] + )) + ); + } +} diff --git a/tests/Models/Shipments.php b/tests/Models/Shipments.php new file mode 100644 index 0000000..6c4bb7c --- /dev/null +++ b/tests/Models/Shipments.php @@ -0,0 +1,18 @@ +map(transformations: static fn(Shipment $shipment): array => $shipment->toArray()) + ->toArray(); + } +} diff --git a/tests/Models/Status.php b/tests/Models/Status.php index b1e56bb..0ccad46 100644 --- a/tests/Models/Status.php +++ b/tests/Models/Status.php @@ -9,4 +9,3 @@ enum Status: int case PAID = 1; case PENDING = 0; } -