From 2cac0fd2cba1c448ce9c4ce255c903060f7138e7 Mon Sep 17 00:00:00 2001 From: Arif Hoque Date: Sat, 3 Oct 2026 12:15:43 +0600 Subject: [PATCH 1/2] Collection: bug fixes, dot notation, multi-key sorting, 21 new methods, higher-order proxies --- phpstan-baseline.neon | 6 - src/Phaseolies/Support/Collection.php | 705 +++++++++++++++--- .../Support/HigherOrderCollectionProxy.php | 57 ++ tests/CollectionFeaturesTest.php | 471 ++++++++++++ 4 files changed, 1141 insertions(+), 98 deletions(-) create mode 100644 src/Phaseolies/Support/HigherOrderCollectionProxy.php create mode 100644 tests/CollectionFeaturesTest.php diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 5612c108..59ef7f6e 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1614,12 +1614,6 @@ parameters: count: 2 path: src/Phaseolies/Support/Collection.php - - - message: '#^Unsafe usage of new static\(\)\.$#' - identifier: new.static - count: 16 - path: src/Phaseolies/Support/Collection.php - - message: '#^PHPDoc tag @return with type void is incompatible with native type bool\.$#' identifier: return.phpDocType diff --git a/src/Phaseolies/Support/Collection.php b/src/Phaseolies/Support/Collection.php index f84b75e3..1d9d854d 100644 --- a/src/Phaseolies/Support/Collection.php +++ b/src/Phaseolies/Support/Collection.php @@ -10,6 +10,9 @@ use ArrayAccess; use JsonSerializable; +/** + * @phpstan-consistent-constructor + */ class Collection extends RamseyCollection implements IteratorAggregate, ArrayAccess, JsonSerializable { /** @@ -22,6 +25,16 @@ class Collection extends RamseyCollection implements IteratorAggregate, ArrayAcc */ protected $model; + /** + * Methods that can be used as `$collection->method->property` or `$collection->method->call()`. + * + * @var array + */ + protected static array $proxies = [ + 'filter', 'reject', 'sum', 'avg', 'min', 'max', 'sortBy', 'sortByDesc', + 'groupBy', 'keyBy', 'unique', 'every', 'some', 'flatMap', + ]; + /** * @param string $model * @param array|null $data @@ -42,50 +55,14 @@ public function __construct(string $model, ?array $data = []) */ public function __get($name) { - if ($name === 'map') { - return new class($this) { - protected $collection; - - public function __construct($collection) - { - $this->collection = $collection; - } - - public function __get($property) - { - return $this->collection->map(function ($item) use ($property) { - if ($item === null) { - return null; - } - if (is_array($item)) { - return $item[$property] ?? null; - } elseif (is_object($item)) { - return $item->{$property} ?? null; - } - return null; - }); - } - }; + // `map` and `each` have always taken precedence over a data key of the same name. + if ($name === 'map' || $name === 'each') { + return new HigherOrderCollectionProxy($this, $name); } - if ($name === 'each') { - return new class($this) { - protected $collection; - - public function __construct($collection) - { - $this->collection = $collection; - } - - public function __call($method, $parameters) - { - return $this->collection->each(function ($item) use ($method, $parameters) { - if ($item !== null && method_exists($item, $method)) { - $item->{$method}(...$parameters); - } - }); - } - }; + // The newer ones only apply when no data key has that name. + if (in_array($name, static::$proxies, true) && !array_key_exists($name, $this->data)) { + return new HigherOrderCollectionProxy($this, $name); } return $this->data[$name] ?? null; @@ -133,6 +110,12 @@ public function offsetGet($offset): mixed */ public function offsetSet($offset, $value): void { + if ($offset === null) { + $this->data[] = $value; + + return; + } + $this->data[$offset] = $value; } @@ -188,17 +171,39 @@ public function all(): array } /** - * Get the first item in the collection + * Get the first item, or the first item that passes a test. * + * @param callable|null $callback fn($item, $key): bool + * @param mixed $default * @return mixed */ - public function first(): mixed + public function first(?callable $callback = null, mixed $default = null): mixed { - foreach ($this->data as $item) { - return $item; + foreach ($this->data as $key => $item) { + if ($callback === null || $callback($item, $key)) { + return $item; + } } - return null; + return $default instanceof \Closure ? $default() : $default; + } + + /** + * Get the last item, or the last item that passes a test. + * + * @param callable|null $callback fn($item, $key): bool + * @param mixed $default + * @return mixed + */ + public function last(?callable $callback = null, mixed $default = null): mixed + { + foreach (array_reverse($this->data, true) as $key => $item) { + if ($callback === null || $callback($item, $key)) { + return $item; + } + } + + return $default instanceof \Closure ? $default() : $default; } /** @@ -217,7 +222,7 @@ public function toArray(): array * @param callable $callback * @return static */ - public function map(callable $callback): self + public function map(callable $callback): static { $mappedItems = []; @@ -229,17 +234,18 @@ public function map(callable $callback): self } /** - * Filter the collection using the given callback. + * Filter the collection using the given callback. Without a callback, every + * item that is "falsy" (null, false, 0, '', []) is removed. * - * @param callable $callback + * @param callable|null $callback * @return static */ - public function filter(callable $callback): self + public function filter(?callable $callback = null): static { $filteredItems = []; foreach ($this->data as $key => $item) { - if ($callback($item, $key)) { + if ($callback === null ? (bool) $item : $callback($item, $key)) { $filteredItems[] = $item; } } @@ -253,7 +259,7 @@ public function filter(callable $callback): self * @param callable $callback * @return $this */ - public function each(callable $callback): self + public function each(callable $callback): static { foreach ($this->data as $key => $item) { if ($callback($item, $key) === false) { @@ -270,7 +276,7 @@ public function each(callable $callback): self * @param mixed $item * @return $this */ - public function push(mixed $item): self + public function push(mixed $item): static { $this->data[] = $item; @@ -283,7 +289,7 @@ public function push(mixed $item): self * @param int $depth * @return static */ - public function flatten(int $depth = PHP_INT_MAX): self + public function flatten(int $depth = PHP_INT_MAX): static { $result = []; $stack = []; @@ -327,9 +333,9 @@ public function flatten(int $depth = PHP_INT_MAX): self /** * Get the values * - * @return self + * @return static */ - public function values(): self + public function values(): static { return new static($this->model, array_values($this->data)); } @@ -341,17 +347,17 @@ public function values(): self * @param bool $strict * @return static */ - public function unique(?string $key = null, bool $strict = false): self + public function unique(?string $key = null, bool $strict = false): static { $uniqueItems = []; $exists = []; foreach ($this->data as $item) { - $value = $key !== null - ? (is_array($item) ? ($item[$key] ?? null) : ($item->$key ?? null)) - : $item; + $value = $key !== null ? $this->valueFrom($item, $key) : $item; - $serialized = $strict ? serialize($value) : (is_scalar($value) ? $value : serialize($value)); + // A float used directly as an array key is truncated to an int, which made + // 1.5 and 1.2 look alike. Scalars are keyed by their string form instead. + $serialized = $strict ? serialize($value) : (is_scalar($value) ? (string) $value : serialize($value)); if (!isset($exists[$serialized])) { $exists[$serialized] = true; @@ -369,18 +375,13 @@ public function unique(?string $key = null, bool $strict = false): self * @param string|null $key * @return static */ - public function pluck(string $value, ?string $key = null): self + public function pluck(string $value, ?string $key = null): static { $results = []; foreach ($this->data as $item) { - if (is_array($item)) { - $itemValue = $item[$value] ?? null; - $itemKey = $key ? ($item[$key] ?? null) : null; - } else { - $itemValue = $item->{$value} ?? null; - $itemKey = $key ? ($item->{$key} ?? null) : null; - } + $itemValue = $this->valueFrom($item, $value); + $itemKey = $key ? $this->valueFrom($item, $key) : null; if ($key === null) { $results[] = $itemValue; @@ -498,24 +499,86 @@ public function mapAsKey($keyBy, ?callable $mapCallback = null): array /** * Build a key resolver from various input types. * - * @param callable|string $key + * @param mixed $key A callback, or the name of a key (dot notation allowed) * @return callable */ protected function buildKeyResolver($key): callable { - if (is_callable($key)) { + // A string is a key, never a callback: `date`, `count` and `time` are also PHP functions. + if (!is_string($key) && is_callable($key)) { return $key; } - return function ($item) use ($key) { - if (is_array($item)) { - return $item[$key] ?? null; - } elseif (is_object($item)) { - return $item->{$key} ?? null; + return fn($item) => $this->valueFrom($item, $key); + } + + /** + * Read a key from an item. Works on arrays, objects (including models) and + * ArrayAccess values, and understands dot notation for nested values: + * `valueFrom($row, 'user.address.city')`. A key that really contains a dot + * is tried as it is first. + * + * @param mixed $item + * @param string|int $key + * @return mixed Null when the value is not there + */ + public function valueFrom(mixed $item, string|int $key): mixed + { + $found = false; + $value = $this->readKey($item, $key, $found); + + if ($found || !is_string($key) || !str_contains($key, '.')) { + return $value; + } + + $current = $item; + + foreach (explode('.', $key) as $segment) { + $current = $this->readKey($current, $segment, $found); + + if (!$found) { + return null; + } + } + + return $current; + } + + /** + * @param mixed $item + * @param string|int $key + * @param bool $found Set to whether the key was there + * @return mixed + */ + private function readKey(mixed $item, string|int $key, bool &$found = false): mixed + { + $found = false; + + if (is_array($item)) { + if (array_key_exists($key, $item)) { + $found = true; + + return $item[$key]; } return null; - }; + } + + if (is_object($item)) { + if (isset($item->{$key})) { + $found = true; + + return $item->{$key}; + } + + if ($item instanceof ArrayAccess && isset($item[$key])) { + $found = true; + + return $item[$key]; + } + } + + return null; } /** @@ -543,15 +606,24 @@ public function keyBy($keyBy, ?callable $mapCallback = null): array } /** - * Sort the collection by a given key or callback. + * Sort the collection by a given key or callback, or by several of them. * - * @param callable|string $callback + * $users->sortBy('age'); + * $users->sortBy('profile.city'); // dot notation + * $users->sortBy(['age', 'name']); // by age, then name + * $users->sortBy([['age', 'desc'], ['name', 'asc']]); // with a direction each + * + * @param callable|string|array $callback * @param int $options * @param bool $descending * @return static */ - public function sortBy($callback, int $options = SORT_REGULAR, bool $descending = false): self + public function sortBy($callback, int $options = SORT_REGULAR, bool $descending = false): static { + if (is_array($callback) && !is_callable($callback)) { + return $this->sortByMany($callback, $options, $descending); + } + $results = []; $resolver = $this->buildKeyResolver($callback); @@ -574,6 +646,69 @@ public function sortBy($callback, int $options = SORT_REGULAR, bool $descending return new static($this->model, $sortedData); } + /** + * Sort by several criteria; each is a key, a callback, or [key, 'asc'|'desc']. + * + * @param array $criteria + * @param int $options + * @param bool $descending + * @return static + */ + protected function sortByMany(array $criteria, int $options, bool $descending): static + { + $resolvers = []; + + foreach ($criteria as $criterion) { + $direction = $descending; + + if (is_array($criterion) && !is_callable($criterion)) { + [$criterion, $named] = array_pad(array_values($criterion), 2, null); + $direction = $named === null ? $descending : (is_bool($named) ? $named : strtolower((string) $named) === 'desc'); + } + + $resolvers[] = [$this->buildKeyResolver($criterion), $direction]; + } + + $keyed = []; + foreach ($this->data as $key => $item) { + $keyed[] = [$item, array_map(fn($r) => $r[0]($item, $key), $resolvers)]; + } + + // usort is stable, so items that tie on every criterion keep their order. + usort($keyed, function ($a, $b) use ($resolvers, $options) { + foreach ($resolvers as $i => [, $desc]) { + $result = $this->compareValues($a[1][$i], $b[1][$i], $options); + + if ($result !== 0) { + return $desc ? -$result : $result; + } + } + + return 0; + }); + + return new static($this->model, array_column($keyed, 0)); + } + + /** + * @param mixed $a + * @param mixed $b + * @param int $options One of the SORT_* flags, optionally with SORT_FLAG_CASE + * @return int + */ + protected function compareValues(mixed $a, mixed $b, int $options): int + { + $flags = $options & ~SORT_FLAG_CASE; + $caseless = ($options & SORT_FLAG_CASE) === SORT_FLAG_CASE; + + return match ($flags) { + SORT_STRING => $caseless ? strcasecmp((string) $a, (string) $b) <=> 0 : strcmp((string) $a, (string) $b) <=> 0, + SORT_NATURAL => $caseless ? strnatcasecmp((string) $a, (string) $b) : strnatcmp((string) $a, (string) $b), + SORT_NUMERIC => (float) $a <=> (float) $b, + default => $a <=> $b, + }; + } + /** * Sort the collection in descending order by a given key or callback. * @@ -581,7 +716,7 @@ public function sortBy($callback, int $options = SORT_REGULAR, bool $descending * @param int $options * @return static */ - public function sortByDesc($callback, int $options = SORT_REGULAR): self + public function sortByDesc($callback, int $options = SORT_REGULAR): static { return $this->sortBy($callback, $options, true); } @@ -645,7 +780,7 @@ public function toJson(int $options = 0): string * @param int $limit * @return static */ - public function take(int $limit): self + public function take(int $limit): static { if ($limit < 0) { return $this->takeLast(abs($limit)); @@ -660,7 +795,7 @@ public function take(int $limit): self * @param int $limit * @return static */ - public function takeLast(int $limit): self + public function takeLast(int $limit): static { if ($limit <= 0) { return new static($this->model, []); @@ -711,6 +846,37 @@ public function __unserialize(array $data): void } } + /** + * The type of the items, as the parent collection reports it. Doppar collections + * hold anything, so this is the model they were created for. + * + * @return string + */ + public function getType(): string + { + return $this->model ?: 'mixed'; + } + + /** + * Combine this collection with other collections or arrays into a new one. + * Numeric keys are renumbered; a string key from a later one replaces an earlier one. + * + * @param iterable ...$collections + * @return static + */ + public function merge(iterable ...$collections): static + { + $merged = [$this->data]; + + foreach ($collections as $collection) { + $merged[] = $collection instanceof self + ? $collection->all() + : (is_array($collection) ? $collection : iterator_to_array($collection)); + } + + return new static($this->model, array_merge(...$merged)); + } + /** * Get the model class name associated to this collection. * @@ -727,7 +893,7 @@ public function getModel(): ?string * @param int $size * @return static */ - public function chunk(int $size): self + public function chunk(int $size): static { if ($size <= 0) { throw new \InvalidArgumentException('Chunk size must be greater than 0'); @@ -772,7 +938,7 @@ public function partition(callable $callback): array * @param mixed $items * @return static */ - public function diff($items): self + public function diff($items): static { $compare = $items instanceof self ? $items->all() : (array) $items; @@ -792,7 +958,7 @@ public function diff($items): self * @param mixed $items * @return static */ - public function intersect($items): self + public function intersect($items): static { $compare = $items instanceof self ? $items->all() : (array) $items; @@ -812,7 +978,7 @@ public function intersect($items): self * @param callable $callback * @return $this */ - public function tap(callable $callback): self + public function tap(callable $callback): static { $callback($this); @@ -836,15 +1002,13 @@ public function pipe(callable $callback): mixed * @param string|null $key * @return static */ - public function duplicates(?string $key = null): self + public function duplicates(?string $key = null): static { $seen = []; $duplicates = []; foreach ($this->data as $item) { - $value = $key !== null - ? (is_array($item) ? ($item[$key] ?? null) : ($item->$key ?? null)) - : $item; + $value = $key !== null ? $this->valueFrom($item, $key) : $item; $serialized = is_scalar($value) ? (string) $value : serialize($value); @@ -964,6 +1128,363 @@ public function sole(?callable $callback = null): mixed return $filtered->first(); } + /** + * Get the items that do not pass the test (the opposite of filter). + * + * @param callable $callback fn($item, $key): bool + * @return static + */ + public function reject(callable $callback): static + { + return $this->filter(fn($item, $key) => !$callback($item, $key)); + } + + /** + * Determine whether every item passes the test. True for an empty collection. + * + * @param callable $callback fn($item, $key): bool + * @return bool + */ + public function every(callable $callback): bool + { + foreach ($this->data as $key => $item) { + if (!$callback($item, $key)) { + return false; + } + } + + return true; + } + + /** + * Determine whether at least one item passes the test. False for an empty collection. + * + * @param callable $callback fn($item, $key): bool + * @return bool + */ + public function some(callable $callback): bool + { + foreach ($this->data as $key => $item) { + if ($callback($item, $key)) { + return true; + } + } + + return false; + } + + /** + * Reverse the order of the items. + * + * @return static + */ + public function reverse(): static + { + return new static($this->model, array_reverse($this->data)); + } + + /** + * Get the keys of the collection as a new collection. + * + * @return static + */ + public function keys(): static + { + return new static($this->model, array_keys($this->data)); + } + + /** + * Keep only the items with the given keys. + * + * @param array $keys + * @return static + */ + public function only(array $keys): static + { + return new static($this->model, array_intersect_key($this->data, array_flip($keys))); + } + + /** + * Remove the items with the given keys. + * + * @param array $keys + * @return static + */ + public function except(array $keys): static + { + return new static($this->model, array_diff_key($this->data, array_flip($keys))); + } + + /** + * Get the first item whose value for a key matches. + * + * $users->firstWhere('active'); // truthy + * $users->firstWhere('age', 30); // equal (==) + * $users->firstWhere('age', '>=', 18); // with an operator + * + * @param string $key + * @param mixed $operator + * @param mixed $value + * @return mixed + */ + public function firstWhere(string $key, mixed $operator = null, mixed $value = null): mixed + { + $arguments = func_num_args(); + + return $this->first(function ($item) use ($key, $operator, $value, $arguments) { + $retrieved = $this->valueFrom($item, $key); + + return match ($arguments) { + 1 => (bool) $retrieved, + 2 => $this->compareWith($retrieved, '=', $operator), + default => $this->compareWith($retrieved, (string) $operator, $value), + }; + }); + } + + /** + * @param mixed $retrieved + * @param string $operator + * @param mixed $value + * @return bool + */ + protected function compareWith(mixed $retrieved, string $operator, mixed $value): bool + { + return match ($operator) { + '=', '==' => $retrieved == $value, + '===' => $retrieved === $value, + '!=', '<>' => $retrieved != $value, + '!==' => $retrieved !== $value, + '<' => $retrieved < $value, + '>' => $retrieved > $value, + '<=' => $retrieved <= $value, + '>=' => $retrieved >= $value, + default => throw new \InvalidArgumentException("Unknown comparison operator [{$operator}]."), + }; + } + + /** + * Keep the items whose value for a key is one of the given values. + * + * @param string $key + * @param iterable $values + * @param bool $strict + * @return static + */ + public function whereIn(string $key, iterable $values, bool $strict = false): static + { + $values = is_array($values) ? $values : iterator_to_array($values); + + return $this->filter(fn($item) => in_array($this->valueFrom($item, $key), $values, $strict)); + } + + /** + * Keep the items whose value for a key is none of the given values. + * + * @param string $key + * @param iterable $values + * @param bool $strict + * @return static + */ + public function whereNotIn(string $key, iterable $values, bool $strict = false): static + { + $values = is_array($values) ? $values : iterator_to_array($values); + + return $this->filter(fn($item) => !in_array($this->valueFrom($item, $key), $values, $strict)); + } + + /** + * Keep the items that have a value (not null) for a key. + * + * @param string $key + * @return static + */ + public function whereNotNull(string $key): static + { + return $this->filter(fn($item) => $this->valueFrom($item, $key) !== null); + } + + /** + * Keep the items that have no value (null) for a key. + * + * @param string $key + * @return static + */ + public function whereNull(string $key): static + { + return $this->filter(fn($item) => $this->valueFrom($item, $key) === null); + } + + /** + * Count how many times each value occurs. + * + * @param callable|string|null $callback A key or callback that picks the value; the item itself by default + * @return array + */ + public function countBy(callable|string|null $callback = null): array + { + $resolver = $callback === null ? fn($item) => $item : $this->buildKeyResolver($callback); + $counts = []; + + foreach ($this->data as $key => $item) { + $value = $resolver($item, $key); + $value = is_bool($value) ? (int) $value : (is_scalar($value) || $value === null ? (string) $value : serialize($value)); + $counts[$value] = ($counts[$value] ?? 0) + 1; + } + + return $counts; + } + + /** + * Join the items into a string, or one key of each item. + * + * @param string $glue + * @param string|null $key + * @return string + */ + public function implode(string $glue, ?string $key = null): string + { + $values = $key === null ? $this->data : $this->pluck($key)->all(); + + return implode($glue, array_map(fn($value) => (string) $value, $values)); + } + + /** + * Map each item to an array or collection, then join the results into one level. + * + * @param callable $callback + * @return static + */ + public function flatMap(callable $callback): static + { + return $this->map($callback)->collapse(); + } + + /** + * Join an array of arrays (or collections) into a single level. + * + * @return static + */ + public function collapse(): static + { + return $this->flatten(1); + } + + /** + * Skip the first items. + * + * @param int $count + * @return static + */ + public function skip(int $count): static + { + return new static($this->model, array_slice($this->data, max(0, $count))); + } + + /** + * Get a part of the collection. + * + * @param int $offset + * @param int|null $length + * @return static + */ + public function slice(int $offset, ?int $length = null): static + { + return new static($this->model, array_slice($this->data, $offset, $length)); + } + + /** + * Add items at the end. Numeric keys are renumbered. + * + * @param iterable $items + * @return static + */ + public function concat(iterable $items): static + { + return $this->merge(is_array($items) ? array_values($items) : array_values(iterator_to_array($items))); + } + + /** + * Find the key of a value, or of the first item that passes a test. + * + * @param mixed $value A value, or a callable fn($item, $key): bool + * @param bool $strict + * @return int|string|false + */ + public function search(mixed $value, bool $strict = false): int|string|false + { + $isTest = !is_string($value) && is_callable($value); + + foreach ($this->data as $key => $item) { + if ($isTest ? $value($item, $key) : ($strict ? $item === $value : $item == $value)) { + return $key; + } + } + + return false; + } + + /** + * The middle value. Items that are null are ignored. + * + * @param string|null $key Take the value from this key of each item + * @return int|float|null Null when there are no values + */ + public function median(?string $key = null): int|float|null + { + $values = $key === null ? $this->data : $this->pluck($key)->all(); + $values = array_values(array_filter($values, fn($value) => $value !== null)); + + if ($values === []) { + return null; + } + + sort($values); + $middle = intdiv(count($values), 2); + + return count($values) % 2 === 1 + ? $values[$middle] + : ($values[$middle - 1] + $values[$middle]) / 2; + } + + /** + * Run a callback when the value is truthy. A Closure value is called with the collection first. + * + * @param mixed $value + * @param callable $callback fn($collection, $value) + * @param callable|null $default Runs when the value is falsy + * @return static|mixed + */ + public function when(mixed $value, callable $callback, ?callable $default = null): mixed + { + $value = $value instanceof \Closure ? $value($this) : $value; + + if ($value) { + return $callback($this, $value) ?? $this; + } + + return $default ? ($default($this, $value) ?? $this) : $this; + } + + /** + * Run a callback when the value is falsy. + * + * @param mixed $value + * @param callable $callback fn($collection, $value) + * @param callable|null $default Runs when the value is truthy + * @return static|mixed + */ + public function unless(mixed $value, callable $callback, ?callable $default = null): mixed + { + $value = $value instanceof \Closure ? $value($this) : $value; + + if (!$value) { + return $callback($this, $value) ?? $this; + } + + return $default ? ($default($this, $value) ?? $this) : $this; + } + /** * Get all items in the collection * diff --git a/src/Phaseolies/Support/HigherOrderCollectionProxy.php b/src/Phaseolies/Support/HigherOrderCollectionProxy.php new file mode 100644 index 00000000..88277582 --- /dev/null +++ b/src/Phaseolies/Support/HigherOrderCollectionProxy.php @@ -0,0 +1,57 @@ +map->name // same as map(fn($u) => $u->name) + * $users->filter->isActive() // same as filter(fn($u) => $u->isActive()) + * $users->sum->points // same as sum(fn($u) => $u->points) + * $users->each->save() // calls save() on every item that has it + */ +class HigherOrderCollectionProxy +{ + /** + * @param Collection $collection + * @param string $method The collection method to apply + */ + public function __construct(protected Collection $collection, protected string $method) + { + } + + /** + * Apply the collection method with a callback that reads a property of each item. + * + * @param string $property + * @return mixed + */ + public function __get(string $property): mixed + { + return $this->collection->{$this->method}( + fn($item) => $this->collection->valueFrom($item, $property) + ); + } + + /** + * Apply the collection method with a callback that calls a method on each item. + * + * @param string $name + * @param array $arguments + * @return mixed + */ + public function __call(string $name, array $arguments): mixed + { + // each() has always skipped items that do not have the method. + if ($this->method === 'each') { + return $this->collection->each(function ($item) use ($name, $arguments) { + if (is_object($item) && method_exists($item, $name)) { + $item->{$name}(...$arguments); + } + }); + } + + return $this->collection->{$this->method}(fn($item) => $item->{$name}(...$arguments)); + } +} diff --git a/tests/CollectionFeaturesTest.php b/tests/CollectionFeaturesTest.php new file mode 100644 index 00000000..9020a668 --- /dev/null +++ b/tests/CollectionFeaturesTest.php @@ -0,0 +1,471 @@ +collect([ + ['id' => 1, 'name' => 'ada', 'age' => 30, 'date' => '2025-03-01', 'count' => 5, 'profile' => ['city' => 'Paris']], + ['id' => 2, 'name' => 'bob', 'age' => 25, 'date' => '2025-01-01', 'count' => 9, 'profile' => ['city' => 'Oslo']], + ['id' => 3, 'name' => 'cy', 'age' => 30, 'date' => '2025-02-01', 'count' => 7, 'profile' => ['city' => 'Rome']], + ]); + } + + private function objects(): Collection + { + $make = fn(string $name, int $age, bool $active) => new class($name, $age, $active) { + public array $saved = []; + + public function __construct(public string $name, public int $age, public bool $active) + { + } + + public function isAdult(): bool + { + return $this->age >= 18; + } + + public function save(string $note = ''): void + { + $this->saved[] = $note; + } + }; + + return $this->collect([$make('ada', 30, true), $make('bob', 12, false), $make('cy', 40, true)]); + } + + // ----- fixes ------------------------------------------------------------------------ + + public function testAppendingWithBracketsAndAddActuallyAppends(): void + { + $c = $this->collect([1]); + $c[] = 2; + $c[] = 3; + + $this->assertSame([1, 2, 3], $c->all()); + + $this->assertTrue($c->add(4)); + $this->assertTrue($c->add(5)); + $this->assertSame([1, 2, 3, 4, 5], $c->all()); + } + + public function testSettingAKeyStillWorks(): void + { + $c = $this->collect(['a' => 1]); + $c['b'] = 2; + + $this->assertSame(['a' => 1, 'b' => 2], $c->all()); + } + + public function testFirstAcceptsATestAndADefault(): void + { + $c = $this->collect([1, 2, 3, 4]); + + $this->assertSame(1, $c->first()); + $this->assertSame(3, $c->first(fn($n) => $n > 2)); + $this->assertNull($c->first(fn($n) => $n > 9)); + $this->assertSame('none', $c->first(fn($n) => $n > 9, 'none')); + $this->assertSame('lazy', $c->first(fn($n) => $n > 9, fn() => 'lazy')); + $this->assertSame('empty', $this->collect()->first(null, 'empty')); + } + + public function testFirstPassesTheKeyToTheTest(): void + { + $c = $this->collect(['a' => 1, 'b' => 2]); + + $this->assertSame(2, $c->first(fn($v, $k) => $k === 'b')); + } + + public function testLastAcceptsATestAndADefault(): void + { + $c = $this->collect([1, 2, 3, 4]); + + $this->assertSame(4, $c->last()); + $this->assertSame(3, $c->last(fn($n) => $n < 4)); + $this->assertSame('none', $c->last(fn($n) => $n > 9, 'none')); + $this->assertNull($this->collect()->last()); + } + + public function testFilterWithoutACallbackDropsFalsyItems(): void + { + $this->assertSame([1, 'a', true], $this->collect([0, 1, null, 'a', false, '', [], true])->filter()->all()); + } + + public function testFilterWithACallbackIsUnchanged(): void + { + $this->assertSame([2, 4], $this->collect([1, 2, 3, 4])->filter(fn($n) => $n % 2 === 0)->all()); + } + + public function testUniqueTellsFloatsApart(): void + { + $deprecations = []; + set_error_handler(function ($no, $message) use (&$deprecations) { + $deprecations[] = $message; + + return true; + }); + + try { + $result = $this->collect([1.5, 1.2, 1.5, 1.2, 2.0])->unique()->all(); + } finally { + restore_error_handler(); + } + + $this->assertSame([1.5, 1.2, 2.0], $result); + $this->assertSame([], $deprecations, 'no "implicit conversion from float" deprecations'); + } + + public function testUniqueStillTreatsLooselyEqualScalarsAsTheSame(): void + { + $this->assertSame(['1'], $this->collect(['1', 1, true])->unique()->all()); + $this->assertSame([1, '1'], $this->collect([1, '1'])->unique(null, true)->all()); + } + + public function testAStringIsAColumnNameNeverAFunction(): void + { + $users = $this->users(); + + $this->assertSame([1, 2, 3], array_map(fn($g) => $g[0]['id'], array_values($users->groupBy('date')))); + $this->assertSame(['2025-03-01', '2025-01-01', '2025-02-01'], array_keys($users->keyBy('date'))); + $this->assertSame(['bob', 'cy', 'ada'], $users->sortBy('date')->pluck('name')->all()); + $this->assertSame(['ada', 'cy', 'bob'], $users->sortBy('count')->pluck('name')->all()); + $this->assertSame(['bob', 'cy', 'ada'], $users->sortByDesc('count')->pluck('name')->all()); + } + + public function testACallableValueIsStillUsedAsACallback(): void + { + $this->assertSame([1, 2, 3], array_keys($this->users()->keyBy(fn($u) => $u['id']))); + } + + public function testTheParentCollectionMethodsThatCrashedNowWork(): void + { + $c = $this->collect([1]); + + $this->assertSame(\stdClass::class, $c->getType()); + $this->assertSame([1, 2, 3], $c->merge($this->collect([2]), [3])->all()); + } + + public function testMergeFollowsArrayMergeRules(): void + { + $merged = $this->collect(['a' => 1, 5 => 'x'])->merge(['a' => 2, 9 => 'y'], new \ArrayIterator([7 => 'z'])); + + $this->assertSame(['a' => 2, 0 => 'x', 1 => 'y', 2 => 'z'], $merged->all()); + $this->assertSame(\stdClass::class, $merged->getModel()); + } + + public function testConcatAppendsValues(): void + { + $this->assertSame([1, 2, 3, 4], $this->collect([1, 2])->concat([3, 4])->all()); + $this->assertSame([1, 2, 3], $this->collect([1, 2])->concat(new \ArrayIterator(['k' => 3]))->all()); + } + + // ----- dot notation ----------------------------------------------------------------- + + public function testPluckUnderstandsDotNotation(): void + { + $this->assertSame(['Paris', 'Oslo', 'Rome'], $this->users()->pluck('profile.city')->all()); + $this->assertSame(['ada' => 'Paris', 'bob' => 'Oslo', 'cy' => 'Rome'], $this->users()->pluck('profile.city', 'name')->all()); + } + + public function testDotNotationReachesIntoObjectsAndArrayAccess(): void + { + $nested = new class implements ArrayAccess { + private array $data = ['inner' => 'value']; + + public function offsetExists(mixed $o): bool + { + return isset($this->data[$o]); + } + + public function offsetGet(mixed $o): mixed + { + return $this->data[$o]; + } + + public function offsetSet(mixed $o, mixed $v): void + { + } + + public function offsetUnset(mixed $o): void + { + } + }; + $object = (object) ['user' => (object) ['address' => ['zip' => '75001']], 'box' => $nested]; + + $c = $this->collect([$object]); + + $this->assertSame(['75001'], $c->pluck('user.address.zip')->all()); + $this->assertSame(['value'], $c->pluck('box.inner')->all()); + $this->assertSame([null], $c->pluck('user.missing.deeper')->all()); + } + + public function testAKeyThatReallyContainsADotIsTriedFirst(): void + { + $c = $this->collect([['a.b' => 'direct', 'a' => ['b' => 'nested']]]); + + $this->assertSame(['direct'], $c->pluck('a.b')->all()); + } + + public function testSortGroupUniqueAndDuplicatesUseDotNotation(): void + { + $users = $this->users(); + + $this->assertSame(['bob', 'ada', 'cy'], $users->sortBy('profile.city')->pluck('name')->all()); + $this->assertSame(['Paris', 'Oslo', 'Rome'], array_keys($users->groupBy('profile.city'))); + $this->assertCount(3, $users->unique('profile.city')); + $this->assertCount(1, $users->duplicates('age')); + } + + // ----- sorting by several keys ------------------------------------------------------ + + public function testSortByManyKeys(): void + { + $this->assertSame(['bob', 'ada', 'cy'], $this->users()->sortBy(['age', 'name'])->pluck('name')->all()); + } + + public function testSortByManyKeysWithDirections(): void + { + $this->assertSame(['cy', 'ada', 'bob'], $this->users()->sortBy([['age', 'desc'], ['name', 'desc']])->pluck('name')->all()); + $this->assertSame(['ada', 'cy', 'bob'], $this->users()->sortBy([['age', 'desc'], ['name', 'asc']])->pluck('name')->all()); + $this->assertSame(['cy', 'ada', 'bob'], $this->users()->sortBy([['age', true], ['name', true]])->pluck('name')->all()); + } + + public function testSortByManyAcceptsCallbacksAndADefaultDirection(): void + { + $users = $this->users(); + + // no direction named, so both criteria use the default: descending + $this->assertSame(['cy', 'ada', 'bob'], $users->sortBy([fn($u) => $u['age'], 'name'], SORT_REGULAR, true)->pluck('name')->all()); + $this->assertSame(['bob', 'ada', 'cy'], $users->sortBy([fn($u) => $u['age'], fn($u) => $u['name']])->pluck('name')->all()); + } + + public function testSortByManyIsStableAndRespectsSortFlags(): void + { + $c = $this->collect([['n' => 'b10', 'g' => 1], ['n' => 'B2', 'g' => 1], ['n' => 'a1', 'g' => 0]]); + + $this->assertSame(['a1', 'b10', 'B2'], $c->sortBy(['g', 'n'], SORT_STRING | SORT_FLAG_CASE)->pluck('n')->all()); + $this->assertSame(['a1', 'B2', 'b10'], $c->sortBy(['g', 'n'], SORT_NATURAL | SORT_FLAG_CASE)->pluck('n')->all()); + + $ties = $this->collect([['k' => 1, 'v' => 'first'], ['k' => 1, 'v' => 'second']]); + $this->assertSame(['first', 'second'], $ties->sortBy(['k'])->pluck('v')->all()); + } + + public function testSortByAnArrayCallableIsStillACallback(): void + { + $sorter = new class { + public function key(array $u): int + { + return -$u['age']; + } + }; + + $this->assertSame(['ada', 'cy', 'bob'], $this->users()->sortBy([$sorter, 'key'])->pluck('name')->all()); + } + + // ----- new methods ------------------------------------------------------------------ + + public function testReject(): void + { + $this->assertSame([1, 3], $this->collect([1, 2, 3, 4])->reject(fn($n) => $n % 2 === 0)->all()); + } + + public function testEveryAndSome(): void + { + $c = $this->collect([2, 4, 6]); + + $this->assertTrue($c->every(fn($n) => $n % 2 === 0)); + $this->assertFalse($c->every(fn($n) => $n > 2)); + $this->assertTrue($c->some(fn($n) => $n > 5)); + $this->assertFalse($c->some(fn($n) => $n > 6)); + $this->assertTrue($this->collect()->every(fn() => false)); + $this->assertFalse($this->collect()->some(fn() => true)); + } + + public function testReverseKeysOnlyExcept(): void + { + $this->assertSame([3, 2, 1], $this->collect([1, 2, 3])->reverse()->all()); + $this->assertSame(['a', 'b'], $this->collect(['a' => 1, 'b' => 2])->keys()->all()); + $this->assertSame(['a' => 1, 'c' => 3], $this->collect(['a' => 1, 'b' => 2, 'c' => 3])->only(['a', 'c'])->all()); + $this->assertSame(['b' => 2], $this->collect(['a' => 1, 'b' => 2, 'c' => 3])->except(['a', 'c'])->all()); + } + + public function testFirstWhere(): void + { + $users = $this->users(); + + $this->assertSame('ada', $users->firstWhere('age', 30)['name']); + $this->assertSame('bob', $users->firstWhere('age', '<', 30)['name']); + $this->assertSame('bob', $users->firstWhere('count', '>=', 7)['name']); + $this->assertSame('ada', $users->firstWhere('name', '===', 'ada')['name']); + $this->assertSame('bob', $users->firstWhere('name', '!=', 'ada')['name']); + $this->assertSame('ada', $users->firstWhere('id')['name'], 'one argument means truthy'); + $this->assertNull($users->firstWhere('age', 99)); + $this->assertSame('Oslo', $users->firstWhere('profile.city', 'Oslo')['profile']['city']); + } + + public function testFirstWhereRejectsAnUnknownOperator(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Unknown comparison operator [~]'); + + $this->users()->firstWhere('age', '~', 30); + } + + public function testWhereInWhereNotInAndNullChecks(): void + { + $users = $this->users(); + $rows = $this->collect([['v' => 1], ['v' => null], ['v' => '1'], []]); + + $this->assertSame(['ada', 'cy'], $users->whereIn('age', [30])->pluck('name')->all()); + $this->assertSame(['bob'], $users->whereNotIn('age', [30])->pluck('name')->all()); + $this->assertCount(2, $rows->whereIn('v', [1]), 'loose by default'); + $this->assertCount(1, $rows->whereIn('v', [1], true)); + $this->assertCount(2, $rows->whereNotNull('v')); + $this->assertCount(2, $rows->whereNull('v')); + $this->assertSame(['ada', 'bob'], $users->whereIn('profile.city', new \ArrayIterator(['Paris', 'Oslo']))->pluck('name')->all()); + } + + public function testCountBy(): void + { + $this->assertSame(['a' => 2, 'b' => 1], $this->collect(['a', 'b', 'a'])->countBy()); + $this->assertSame([30 => 2, 25 => 1], $this->users()->countBy('age')); + $this->assertSame(['odd' => 2, 'even' => 1], $this->collect([1, 2, 3])->countBy(fn($n) => $n % 2 ? 'odd' : 'even')); + $this->assertSame([1 => 2, 0 => 1], $this->collect([true, false, true])->countBy()); + } + + public function testImplode(): void + { + $this->assertSame('1, 2, 3', $this->collect([1, 2, 3])->implode(', ')); + $this->assertSame('ada|bob|cy', $this->users()->implode('|', 'name')); + $this->assertSame('', $this->collect()->implode(',')); + } + + public function testFlatMapAndCollapse(): void + { + $this->assertSame([1, 2, 3, 4], $this->collect([[1, 2], [3], [4]])->collapse()->all()); + $this->assertSame([1, 2, 3, 4], $this->collect([$this->collect([1, 2]), [3], 4])->collapse()->all()); + $this->assertSame(['a', 'b', 'c'], $this->collect(['ab', 'c'])->flatMap(fn($s) => str_split($s))->all()); + } + + public function testSkipAndSlice(): void + { + $c = $this->collect([1, 2, 3, 4, 5]); + + $this->assertSame([3, 4, 5], $c->skip(2)->all()); + $this->assertSame([], $c->skip(9)->all()); + $this->assertSame([1, 2, 3, 4, 5], $c->skip(-3)->all()); + $this->assertSame([2, 3], $c->slice(1, 2)->all()); + $this->assertSame([4, 5], $c->slice(3)->all()); + $this->assertSame([4, 5], $c->slice(-2)->all()); + } + + public function testSearch(): void + { + $c = $this->collect(['a' => 1, 'b' => '2', 'c' => 3]); + + $this->assertSame('c', $c->search(3)); + $this->assertSame('b', $c->search(2)); + $this->assertFalse($c->search(2, true)); + $this->assertSame('c', $c->search(fn($v, $k) => $v > 2)); + $this->assertFalse($c->search(99)); + $this->assertSame('b', $this->collect(['a' => 'strtoupper', 'b' => 'x'])->search('x'), 'a string is a value, not a callback'); + } + + public function testMedian(): void + { + $this->assertSame(3, $this->collect([5, 1, 3])->median()); + $this->assertSame(2.5, $this->collect([1, 2, 3, 4])->median()); + $this->assertSame(2, $this->collect([1, null, 3, null, 2])->median()); + $this->assertNull($this->collect()->median()); + $this->assertSame(30, $this->users()->median('age')); + } + + public function testWhenAndUnless(): void + { + $c = $this->collect([1, 2]); + + $this->assertSame([1, 2, 3], $c->when(true, fn($c) => $c->concat([3]))->all()); + $this->assertSame([1, 2], $c->when(false, fn($c) => $c->concat([3]))->all()); + $this->assertSame([9], $c->when(false, fn($c) => $c, fn($c) => $this->collect([9]))->all()); + $this->assertSame([1, 2, 7], $c->when(fn($c) => $c->count() === 2, fn($c) => $c->concat([7]))->all()); + $this->assertSame($c, $c->when(true, fn($c) => null), 'a callback that returns nothing leaves the collection'); + + $this->assertSame([1, 2, 5], $c->unless(false, fn($c) => $c->concat([5]))->all()); + $this->assertSame([1, 2], $c->unless(true, fn($c) => $c->concat([5]))->all()); + $this->assertSame([8], $c->unless(true, fn($c) => $c, fn($c) => $this->collect([8]))->all()); + } + + // ----- higher-order access ---------------------------------------------------------- + + public function testMapAndEachProxiesStillWork(): void + { + $people = $this->objects(); + + $this->assertSame(['ada', 'bob', 'cy'], $people->map->name->all()); + $this->assertInstanceOf(HigherOrderCollectionProxy::class, $people->map); + + $people->each->save('hello'); + $this->assertSame(['hello'], $people->first()->saved); + $this->assertSame(['hello'], $people->last()->saved); + + // each() skips items that do not have the method + $mixed = $this->collect([$people->first(), 5, ['x']]); + $mixed->each->save('again'); + $this->assertSame(['hello', 'again'], $people->first()->saved); + } + + public function testMapProxyOnArraysAndNulls(): void + { + $this->assertSame(['ada', 'bob', 'cy'], $this->users()->map->name->all()); + $this->assertSame([1, null], $this->collect([['a' => 1], null])->map->a->all()); + } + + public function testNewProxiesReadPropertiesAndCallMethods(): void + { + $people = $this->objects(); + + $this->assertSame(['ada', 'cy'], $people->filter->active->map->name->all()); + $this->assertSame(['ada', 'cy'], $people->filter->isAdult()->map->name->all()); + $this->assertSame(['bob'], $people->reject->active->map->name->all()); + $this->assertSame(82, $people->sum->age); + $this->assertSame(40, $people->max->age); + $this->assertSame(12, $people->min->age); + $this->assertSame(['bob', 'ada', 'cy'], $people->sortBy->age->map->name->all()); + $this->assertSame(['cy', 'ada', 'bob'], $people->sortByDesc->age->map->name->all()); + $this->assertTrue($people->some->isAdult()); + $this->assertFalse($people->every->isAdult()); + $this->assertCount(2, $people->groupBy->active); + } + + public function testADataKeyBeatsTheNewProxyNamesButNotMapAndEach(): void + { + $c = $this->collect(['filter' => 'a value', 'sum' => 10, 'map' => 'shadowed']); + + $this->assertSame('a value', $c->filter); + $this->assertSame(10, $c->sum); + $this->assertInstanceOf(HigherOrderCollectionProxy::class, $c->map, 'map has always been the proxy'); + $this->assertNull($this->collect([1])->name); + } + + public function testNewMethodsKeepTheModel(): void + { + $c = new Collection('App\Models\User', [3, 1, 2]); + + foreach ([$c->reject(fn() => false), $c->reverse(), $c->keys(), $c->only([0]), $c->except([0]), $c->skip(1), $c->slice(1), $c->concat([4]), $c->collapse(), $c->flatMap(fn($n) => [$n]), $c->whereNotNull('x'), $c->merge([1]), $c->sortBy(['a'])] as $result) { + $this->assertInstanceOf(Collection::class, $result); + $this->assertSame('App\Models\User', $result->getModel()); + } + } +} From 24ace0d13b7d451c5adea550f02566ba91f1dd03 Mon Sep 17 00:00:00 2001 From: Arif Hoque Date: Sat, 3 Oct 2026 12:37:04 +0600 Subject: [PATCH 2/2] added some function to streamcollection after update collection class --- phpstan-baseline.neon | 6 - src/Phaseolies/Support/StreamCollection.php | 567 ++++++++++++++++++-- tests/StreamCollectionFeaturesTest.php | 448 ++++++++++++++++ tests/StreamCollectionTest.php | 4 +- 4 files changed, 959 insertions(+), 66 deletions(-) create mode 100644 tests/StreamCollectionFeaturesTest.php diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 59ef7f6e..f0365c85 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -2454,12 +2454,6 @@ parameters: count: 1 path: src/Phaseolies/Support/StreamCollection.php - - - message: '#^Unsafe usage of new static\(\)\.$#' - identifier: new.static - count: 10 - path: src/Phaseolies/Support/StreamCollection.php - - message: '#^Offset 0 on array\{list\\} on left side of \?\? always exists and is not nullable\.$#' identifier: nullCoalesce.offset diff --git a/src/Phaseolies/Support/StreamCollection.php b/src/Phaseolies/Support/StreamCollection.php index a05dfa1c..4184d85e 100644 --- a/src/Phaseolies/Support/StreamCollection.php +++ b/src/Phaseolies/Support/StreamCollection.php @@ -4,11 +4,22 @@ use Traversable; use IteratorAggregate; +use Countable; use Iterator; use Generator; use ArrayIterator; -class StreamCollection implements IteratorAggregate +/** + * A collection that pulls its items one at a time, so large or endless sources can + * be processed without loading them into memory. + * + * A stream built from a closure can be iterated again and again, because the closure + * runs each time. One built from a Generator or Iterator object can only be read once; + * call remember() to be able to iterate it more than once. + * + * @phpstan-consistent-constructor + */ +class StreamCollection implements IteratorAggregate, Countable { /** * The source generator or iterator. @@ -38,16 +49,135 @@ public function __construct($source) } /** - * Create a new lazy collection from a generator function. + * Create a new lazy collection from a generator function, a Generator or + * Iterator, or an array. * - * @param callable $source + * @param callable|Traversable|array $source * @return static */ - public static function make(callable $source): self + public static function make(callable|Traversable|array $source): static { return new static($source); } + /** + * Create a stream of the numbers from $from to $to, one at a time. + * + * @param int|float $from + * @param int|float $to + * @param int|float $step How much to move each time; the direction follows from and to + * @return static + */ + public static function range(int|float $from, int|float $to, int|float $step = 1): static + { + if ($step <= 0) { + throw new \InvalidArgumentException('The step of a range must be greater than 0.'); + } + + return new static(function () use ($from, $to, $step) { + if ($from <= $to) { + for ($n = $from; $n <= $to; $n += $step) { + yield $n; + } + + return; + } + + for ($n = $from; $n >= $to; $n -= $step) { + yield $n; + } + }); + } + + /** + * Create a stream that calls a callback a number of times, passing 1, 2, 3... + * Without a callback the numbers themselves are the items. + * + * @param int $count + * @param callable|null $callback + * @return static + */ + public static function times(int $count, ?callable $callback = null): static + { + return new static(function () use ($count, $callback) { + for ($i = 1; $i <= $count; $i++) { + yield $callback ? $callback($i) : $i; + } + }); + } + + /** + * Create a stream of the lines of a file, read one at a time, without the line breaks. + * + * @param string $path + * @return static + * @throws \InvalidArgumentException + */ + public static function lines(string $path): static + { + if (!is_file($path) || !is_readable($path)) { + throw new \InvalidArgumentException("The file [{$path}] does not exist or is not readable."); + } + + return new static(function () use ($path) { + $handle = fopen($path, 'rb'); + + try { + while (($line = fgets($handle)) !== false) { + yield rtrim($line, "\r\n"); + } + } finally { + fclose($handle); + } + }); + } + + /** + * Remember the items as they are read, so the stream can be iterated more than once + * + * @return static + */ + public function remember(): static + { + $cache = []; + $iterator = null; + $done = false; + $source = $this; + + return new static(function () use (&$cache, &$iterator, &$done, $source) { + $position = 0; + + while (true) { + if ($position < count($cache)) { + [$key, $value] = $cache[$position++]; + + yield $key => $value; + + continue; + } + + if ($done) { + return; + } + + if ($iterator === null) { + $iterator = new \IteratorIterator($source->getIterator()); + $iterator->rewind(); + } else { + $iterator->next(); + } + + if (!$iterator->valid()) { + $done = true; + + return; + } + + $cache[] = [$iterator->key(), $iterator->current()]; + } + }); + } + /** * Get the iterator for the collection. * @@ -80,7 +210,7 @@ public function getIterator(): Traversable * @param callable $callback * @return static */ - public function map(callable $callback): self + public function map(callable $callback): static { return new static(function () use ($callback) { foreach ($this as $key => $value) { @@ -90,36 +220,55 @@ public function map(callable $callback): self } /** - * Filter the collection using the given callback. + * Filter the collection using the given callback. Without a callback, every + * "falsy" item (null, false, 0, '', []) is removed. * - * @param callable $callback + * @param callable|null $callback * @return static */ - public function filter(callable $callback): self + public function filter(?callable $callback = null): static { return new static(function () use ($callback) { foreach ($this as $key => $value) { - if ($callback($value, $key)) { + if ($callback === null ? (bool) $value : $callback($value, $key)) { yield $key => $value; } } }); } + /** + * Keep the items that do not pass the test (the opposite of filter). + * + * @param callable $callback + * @return static + */ + public function reject(callable $callback): static + { + return $this->filter(fn($value, $key) => !$callback($value, $key)); + } + /** * Chunk the collection into chunks of the given size. * * @param int $size * @return static */ - public function chunk(int $size): self + public function chunk(int $size): static { return new static(function () use ($size) { $chunk = []; $count = 0; foreach ($this as $key => $value) { - $chunk[$key] = $value; + // Keys are kept, but a repeated key (as `yield from` produces) must not + // overwrite an item that is already in the chunk. + if (array_key_exists($key, $chunk)) { + $chunk[] = $value; + } else { + $chunk[$key] = $value; + } + $count++; if ($count >= $size) { @@ -136,16 +285,20 @@ public function chunk(int $size): self } /** - * Execute a callback over each item. + * Execute a callback over each item. Return false from the callback to stop. * * @param callable $callback - * @return void + * @return $this */ - public function each(callable $callback): void + public function each(callable $callback): static { foreach ($this as $key => $value) { - $callback($value, $key); + if ($callback($value, $key) === false) { + break; + } } + + return $this; } /** @@ -176,33 +329,54 @@ public function all(): array } /** - * Get the first item from the collection. + * Get the first item, or the first item that passes a test. Stops reading as + * soon as it has found it. * + * @param callable|null $callback fn($item, $key): bool + * @param mixed $default * @return mixed */ - public function first() + public function first(?callable $callback = null, mixed $default = null): mixed { - foreach ($this as $item) { - return $item; + foreach ($this as $key => $item) { + if ($callback === null || $callback($item, $key)) { + return $item; + } } - return null; + return $default instanceof \Closure ? $default() : $default; } /** - * Count the number of items in the collection. + * Get the last item, or the last item that passes a test. Reads the whole stream. * - * @return int + * @param callable|null $callback fn($item, $key): bool + * @param mixed $default + * @return mixed */ - public function count(): int + public function last(?callable $callback = null, mixed $default = null): mixed { - $count = 0; + $found = false; + $last = null; - foreach ($this as $item) { - $count++; + foreach ($this as $key => $item) { + if ($callback === null || $callback($item, $key)) { + $found = true; + $last = $item; + } } - return $count; + return $found ? $last : ($default instanceof \Closure ? $default() : $default); + } + + /** + * Count the number of items in the collection. Reads the whole stream. + * + * @return int + */ + public function count(): int + { + return iterator_count($this); } /** @@ -212,11 +386,11 @@ public function count(): int */ public function isEmpty(): bool { - foreach ($this as $item) { - return false; - } + // Reads at most one item. + $iterator = new \IteratorIterator($this->getIterator()); + $iterator->rewind(); - return true; + return !$iterator->valid(); } /** @@ -236,16 +410,16 @@ public function isNotEmpty(): bool * @param string|null $key * @return static */ - public function pluck(string $value, ?string $key = null): self + public function pluck(string $value, ?string $key = null): static { return new static(function () use ($value, $key) { foreach ($this as $originalKey => $item) { - $itemValue = is_array($item) ? ($item[$value] ?? null) : ($item->{$value} ?? null); + $itemValue = $this->valueFrom($item, $value); if ($key === null) { yield $originalKey => $itemValue; } else { - $itemKey = is_array($item) ? ($item[$key] ?? null) : ($item->{$key} ?? null); + $itemKey = $this->valueFrom($item, $key); if ($itemKey !== null) { yield $itemKey => $itemValue; } @@ -254,13 +428,28 @@ public function pluck(string $value, ?string $key = null): self }); } + /** + * Read a key from an item, with dot notation for nested values. + * + * @param mixed $item + * @param string|int $key + * @return mixed + */ + private function valueFrom(mixed $item, string|int $key): mixed + { + static $reader = null; + $reader ??= new Collection('', []); + + return $reader->valueFrom($item, $key); + } + /** * Flatten the collection. * * @param int $depth The maximum depth to flatten (default: infinite) * @return static */ - public function flatten(int $depth = PHP_INT_MAX): self + public function flatten(int $depth = PHP_INT_MAX): static { return new static(function () use ($depth) { $flattenItem = function ($item, int $currentDepth) use (&$flattenItem, $depth) { @@ -286,38 +475,43 @@ public function flatten(int $depth = PHP_INT_MAX): self } }; + // The inner generators each count from 0, so their keys would repeat and + // all() would keep only the last item of every key. Number the result instead. + $position = 0; + foreach ($this as $item) { - yield from $flattenItem($item, 0); + foreach ($flattenItem($item, 0) as $leaf) { + yield $position++ => $leaf; + } } }); } /** - * Get unique items from the collection. + * Get unique items from the collection. The first of each is kept, and items are + * passed on as they are read, so it works on an endless stream. * * @param string|null $key * @param bool $strict * @return static */ - public function unique(?string $key = null, bool $strict = false): self + public function unique(?string $key = null, bool $strict = false): static { return new static(function () use ($key, $strict) { - $items = []; + $seen = []; foreach ($this as $item) { - $value = $key !== null - ? (is_array($item) ? ($item[$key] ?? null) : ($item->$key ?? null)) - : $item; + $value = $key !== null ? $this->valueFrom($item, $key) : $item; - $serialized = $strict - ? serialize($value) - : (is_scalar($value) ? $value : serialize($value)); + // Keyed by string form, so 1.5 and 1.2 are not both truncated to 1. + $id = $strict ? serialize($value) : (is_scalar($value) ? (string) $value : serialize($value)); - $items[$serialized] = $item; - } + if (isset($seen[$id])) { + continue; + } + + $seen[$id] = true; - // Yield items in insertion order - foreach ($items as $item) { yield $item; } }); @@ -329,7 +523,7 @@ public function unique(?string $key = null, bool $strict = false): self * @param int $count * @return static */ - public function skip(int $count): self + public function skip(int $count): static { return new static(function () use ($count) { $i = 0; @@ -342,31 +536,79 @@ public function skip(int $count): self } /** - * Take the first $count items. + * Take the first $count items, reading no more than that from the source. * * @param int $count * @return static */ - public function take(int $count): self + public function take(int $count): static { return new static(function () use ($count) { - $i = 0; + if ($count <= 0) { + return; + } + + $taken = 0; + foreach ($this as $key => $value) { - if ($i++ < $count) { - yield $key => $value; - } else { + yield $key => $value; + + // Stop before asking the source for another item. + if (++$taken >= $count) { break; } } }); } + /** + * Take items while the test passes, and stop at the first that fails. + * + * @param callable $callback fn($value, $key): bool + * @return static + */ + public function takeWhile(callable $callback): static + { + return new static(function () use ($callback) { + foreach ($this as $key => $value) { + if (!$callback($value, $key)) { + break; + } + + yield $key => $value; + } + }); + } + + /** + * Skip items while the test passes, then take the rest. + * + * @param callable $callback fn($value, $key): bool + * @return static + */ + public function skipWhile(callable $callback): static + { + return new static(function () use ($callback) { + $skipping = true; + + foreach ($this as $key => $value) { + if ($skipping && $callback($value, $key)) { + continue; + } + + $skipping = false; + + yield $key => $value; + } + }); + } + /** * Get the values (reset keys) * * @return static */ - public function values(): self + public function values(): static { return new static(function () { foreach ($this as $value) { @@ -381,7 +623,7 @@ public function values(): self * @param mixed $item * @return $this */ - public function push(mixed $item): self + public function push(mixed $item): static { $currentSource = $this->source; @@ -395,6 +637,215 @@ public function push(mixed $item): self return $this; } + /** + * Add items at the end. Keys are not kept, the items are numbered. + * + * @param iterable $items + * @return static + */ + public function concat(iterable $items): static + { + return new static(function () use ($items) { + foreach ($this as $value) { + yield $value; + } + + foreach ($items as $value) { + yield $value; + } + }); + } + + /** + * Map each item to an array, collection or stream, and join the results into one. + * + * @param callable $callback + * @return static + */ + public function flatMap(callable $callback): static + { + return new static(function () use ($callback) { + foreach ($this as $key => $value) { + $result = $callback($value, $key); + + if (is_iterable($result)) { + foreach ($result as $inner) { + yield $inner; + } + } else { + yield $result; + } + } + }); + } + + /** + * Reduce the stream to a single value. + * + * @param callable $callback fn($carry, $value, $key) + * @param mixed $initial + * @return mixed + */ + public function reduce(callable $callback, mixed $initial = null): mixed + { + $carry = $initial; + + foreach ($this as $key => $value) { + $carry = $callback($carry, $value, $key); + } + + return $carry; + } + + /** + * Add up the items, or a key or callback result of each, reading one item at a time. + * + * @param callable|string|null $callback + * @return int|float + */ + public function sum(callable|string|null $callback = null): int|float + { + return $this->reduce(fn($carry, $value, $key) => $carry + $this->measure($value, $key, $callback), 0); + } + + /** + * The average of the items, or null for an empty stream. + * + * @param callable|string|null $callback + * @return int|float|null + */ + public function avg(callable|string|null $callback = null): int|float|null + { + $count = 0; + $total = 0; + + foreach ($this as $key => $value) { + $count++; + $total += $this->measure($value, $key, $callback); + } + + return $count === 0 ? null : $total / $count; + } + + /** + * The smallest value, or null for an empty stream. + * + * @param callable|string|null $callback + * @return mixed + */ + public function min(callable|string|null $callback = null): mixed + { + $found = false; + $min = null; + + foreach ($this as $key => $value) { + $measured = $this->measure($value, $key, $callback); + + if (!$found || $measured < $min) { + $min = $measured; + $found = true; + } + } + + return $min; + } + + /** + * The largest value, or null for an empty stream. + * + * @param callable|string|null $callback + * @return mixed + */ + public function max(callable|string|null $callback = null): mixed + { + $found = false; + $max = null; + + foreach ($this as $key => $value) { + $measured = $this->measure($value, $key, $callback); + + if (!$found || $measured > $max) { + $max = $measured; + $found = true; + } + } + + return $max; + } + + /** + * @param mixed $value + * @param mixed $key + * @param callable|string|null $callback A key (dot notation allowed) or callback; the value itself by default + * @return mixed + */ + private function measure(mixed $value, mixed $key, callable|string|null $callback): mixed + { + if ($callback === null) { + return $value; + } + + return is_string($callback) ? $this->valueFrom($value, $callback) : $callback($value, $key); + } + + /** + * Determine whether every item passes the test. Stops at the first that fails. + * + * @param callable $callback fn($value, $key): bool + * @return bool + */ + public function every(callable $callback): bool + { + foreach ($this as $key => $value) { + if (!$callback($value, $key)) { + return false; + } + } + + return true; + } + + /** + * Determine whether at least one item passes the test. Stops at the first that does. + * + * @param callable $callback fn($value, $key): bool + * @return bool + */ + public function some(callable $callback): bool + { + foreach ($this as $key => $value) { + if ($callback($value, $key)) { + return true; + } + } + + return false; + } + + /** + * Pass the stream to a callback without changing it. + * + * @param callable $callback + * @return $this + */ + public function tap(callable $callback): static + { + $callback($this); + + return $this; + } + + /** + * Pass the stream to a callback and return what it returns. + * + * @param callable $callback + * @return mixed + */ + public function pipe(callable $callback): mixed + { + return $callback($this); + } + /** * Convert the collection to JSON. * diff --git a/tests/StreamCollectionFeaturesTest.php b/tests/StreamCollectionFeaturesTest.php new file mode 100644 index 00000000..ec942e6c --- /dev/null +++ b/tests/StreamCollectionFeaturesTest.php @@ -0,0 +1,448 @@ + yield from [[1, 2], [3, 4]])->flatten(); + + $this->assertSame([1, 2, 3, 4], $stream->all()); + $this->assertSame([1, 2, 3, 4], $stream->collect()->all()); + $this->assertSame([1, 2, 3, 4], $stream->toArray()); + $this->assertSame('[1,2,3,4]', $stream->toJson()); + } + + public function testFlattenOfNestedStreamsAndCollectionsKeepsEveryItem(): void + { + $inner = new StreamCollection([3, [4]]); + $stream = new StreamCollection([1, new Collection('', [2]), $inner, [[5]]]); + + $this->assertSame([1, 2, 3, 4, 5], $stream->flatten()->all()); + $this->assertSame([1, [2], 3], (new StreamCollection([[1, [2]], 3]))->flatten(1)->all()); + } + + public function testFlattenWithADepthLimit(): void + { + $this->assertSame([1, [2, [3]], 4], (new StreamCollection([[1, [2, [3]]], [4]]))->flatten(1)->all()); + } + + public function testChunkDoesNotLoseItemsWhenKeysRepeat(): void + { + $stream = StreamCollection::make(function () { + yield from [1, 2]; + yield from [3, 4]; + }); + + $chunks = array_map(fn($chunk) => array_values($chunk->all()), iterator_to_array($stream->chunk(4), false)); + + $this->assertSame([[1, 2, 3, 4]], $chunks); + } + + public function testChunkStillKeepsKeysWhenTheyAreUnique(): void + { + $chunks = iterator_to_array((new StreamCollection(['a' => 1, 'b' => 2, 'c' => 3]))->chunk(2), false); + + $this->assertSame(['a' => 1, 'b' => 2], $chunks[0]->all()); + $this->assertSame(['c' => 3], $chunks[1]->all()); + } + + // ----- laziness --------------------------------------------------------------------- + + public function testTakeReadsNoMoreThanItReturns(): void + { + $pulled = []; + + $this->assertSame([1, 2], $this->counting(5, $pulled)->take(2)->all()); + $this->assertSame([1, 2], $pulled, 'the third item must not be requested'); + } + + public function testTakeZeroReadsNothing(): void + { + $pulled = []; + + $this->assertSame([], $this->counting(5, $pulled)->take(0)->all()); + $this->assertSame([], $pulled); + $this->assertSame([], $this->counting(5, $pulled)->take(-3)->all()); + $this->assertSame([], $pulled); + } + + public function testUniqueIsLazyAndWorksOnAnEndlessStream(): void + { + $endless = StreamCollection::make(function () { + $i = 0; + + while (true) { + yield $i++ % 5; + } + }); + + $this->assertSame([0, 1, 2], $endless->unique()->take(3)->all()); + } + + public function testUniqueStopsReadingOnceTheConsumerHasEnough(): void + { + $pulled = []; + + $this->counting(5, $pulled)->unique()->take(2)->all(); + + $this->assertSame([1, 2], $pulled); + } + + public function testUniqueKeepsTheFirstOfEach(): void + { + $stream = new StreamCollection([ + ['id' => 1, 'k' => 'a'], + ['id' => 2, 'k' => 'a'], + ['id' => 3, 'k' => 'b'], + ]); + + $this->assertSame([1, 3], array_column($stream->unique('k')->all(), 'id')); + } + + public function testUniqueTellsFloatsApartWithoutDeprecations(): void + { + $deprecations = []; + set_error_handler(function ($no, $message) use (&$deprecations) { + $deprecations[] = $message; + + return true; + }); + + try { + $result = (new StreamCollection([1.5, 1.2, 1.5, 1.2]))->unique()->all(); + } finally { + restore_error_handler(); + } + + $this->assertSame([1.5, 1.2], $result); + $this->assertSame([], $deprecations); + } + + public function testUniqueByANestedKey(): void + { + $stream = new StreamCollection([['a' => ['b' => 1]], ['a' => ['b' => 1]], ['a' => ['b' => 2]]]); + + $this->assertCount(2, $stream->unique('a.b')->all()); + } + + public function testFirstStopsAtTheFirstMatch(): void + { + $pulled = []; + + $this->assertSame(3, $this->counting(10, $pulled)->first(fn($n) => $n >= 3)); + $this->assertSame([1, 2, 3], $pulled); + } + + public function testEverySomeAndTakeWhileStopEarly(): void + { + $pulled = []; + $this->assertFalse($this->counting(10, $pulled)->every(fn($n) => $n < 3)); + $this->assertSame([1, 2, 3], $pulled); + + $pulled = []; + $this->assertTrue($this->counting(10, $pulled)->some(fn($n) => $n === 2)); + $this->assertSame([1, 2], $pulled); + + $pulled = []; + $this->assertSame([1, 2], $this->counting(10, $pulled)->takeWhile(fn($n) => $n < 3)->all()); + $this->assertSame([1, 2, 3], $pulled, 'it has to read the first item that fails'); + } + + // ----- API -------------------------------------------------------------------------- + + public function testMakeAcceptsEverySourceTheConstructorDoes(): void + { + $this->assertSame([1, 2], StreamCollection::make([1, 2])->all()); + $this->assertSame([1], StreamCollection::make((function () { + yield 1; + })())->all()); + $this->assertSame([1], StreamCollection::make(new \ArrayIterator([1]))->all()); + $this->assertSame([1], StreamCollection::make(fn() => yield 1)->all()); + } + + public function testItCanBeCounted(): void + { + $this->assertInstanceOf(\Countable::class, new StreamCollection([])); + $this->assertSame(3, count(new StreamCollection([1, 2, 3]))); + } + + public function testEachIsChainableAndStopsWhenTheCallbackReturnsFalse(): void + { + $seen = []; + $stream = new StreamCollection([1, 2, 3, 4]); + + $returned = $stream->each(function ($value) use (&$seen) { + $seen[] = $value; + + return $value < 2; + }); + + $this->assertSame($stream, $returned); + $this->assertSame([1, 2], $seen); + } + + public function testFirstAndLastTakeATestAndADefault(): void + { + $stream = new StreamCollection([1, 2, 3, 4]); + + $this->assertSame(2, $stream->first(fn($n) => $n > 1)); + $this->assertSame('none', $stream->first(fn($n) => $n > 9, 'none')); + $this->assertSame('lazy', $stream->first(fn($n) => $n > 9, fn() => 'lazy')); + $this->assertSame(4, $stream->last()); + $this->assertSame(3, $stream->last(fn($n) => $n < 4)); + $this->assertSame('none', $stream->last(fn($n) => $n > 9, 'none')); + $this->assertNull((new StreamCollection([]))->last()); + } + + public function testFilterWithoutACallbackDropsFalsyItems(): void + { + $this->assertSame([1, 'a'], array_values((new StreamCollection([0, 1, null, 'a', false, '', []]))->filter()->all())); + } + + public function testRejectIsTheOppositeOfFilter(): void + { + $this->assertSame([1, 3], array_values((new StreamCollection([1, 2, 3, 4]))->reject(fn($n) => $n % 2 === 0)->all())); + } + + public function testPluckUnderstandsDotNotation(): void + { + $stream = new StreamCollection([['user' => ['name' => 'ada']], ['user' => ['name' => 'bob']], ['user' => []]]); + + $this->assertSame(['ada', 'bob', null], $stream->pluck('user.name')->all()); + } + + // ----- new methods ------------------------------------------------------------------ + + public function testSkipWhile(): void + { + $this->assertSame([3, 4, 1], array_values((new StreamCollection([1, 2, 3, 4, 1]))->skipWhile(fn($n) => $n < 3)->all())); + } + + public function testReduceSumAvgMinMax(): void + { + $stream = new StreamCollection([4, 1, 7]); + + $this->assertSame(12, $stream->reduce(fn($carry, $n) => $carry + $n, 0)); + $this->assertSame(12, $stream->sum()); + $this->assertSame(4.0, (float) $stream->avg()); + $this->assertSame(1, $stream->min()); + $this->assertSame(7, $stream->max()); + } + + public function testAggregatesOfAnEmptyStream(): void + { + $empty = new StreamCollection([]); + + $this->assertSame(0, $empty->sum()); + $this->assertNull($empty->avg()); + $this->assertNull($empty->min()); + $this->assertNull($empty->max()); + $this->assertNull($empty->reduce(fn($c, $n) => $c + $n)); + $this->assertTrue($empty->every(fn() => false)); + $this->assertFalse($empty->some(fn() => true)); + } + + public function testAggregatesByKeyAndCallback(): void + { + $stream = new StreamCollection([['p' => ['n' => 3]], ['p' => ['n' => 5]]]); + + $this->assertSame(8, $stream->sum('p.n')); + $this->assertSame(4.0, (float) $stream->avg('p.n')); + $this->assertSame(3, $stream->min('p.n')); + $this->assertSame(10, $stream->max(fn($row) => $row['p']['n'] * 2)); + $this->assertSame(16, $stream->sum(fn($row) => $row['p']['n'] * 2)); + } + + public function testAMinOrMaxOfZeroOrNegativeValuesIsNotMistakenForNothing(): void + { + $this->assertSame(0, (new StreamCollection([0, 3]))->min()); + $this->assertSame(0, (new StreamCollection([-5, 0]))->max()); + $this->assertSame(-5, (new StreamCollection([-5, 0]))->min()); + } + + public function testConcatNumbersTheItems(): void + { + $stream = (new StreamCollection(['a' => 1]))->concat(new StreamCollection([2, 3]))->concat([4]); + + $this->assertSame([1, 2, 3, 4], $stream->all()); + } + + public function testFlatMap(): void + { + $this->assertSame(['a', 'b', 'c'], (new StreamCollection(['ab', 'c']))->flatMap(fn($s) => str_split($s))->all()); + $this->assertSame([1, 2, 9], (new StreamCollection([[1, 2], 9]))->flatMap(fn($x) => $x)->all()); + $this->assertSame([1, 2], (new StreamCollection([1]))->flatMap(fn($x) => new Collection('', [1, 2]))->all()); + } + + public function testTapAndPipe(): void + { + $stream = new StreamCollection([1, 2]); + $seen = null; + + $this->assertSame($stream, $stream->tap(function ($s) use (&$seen) { + $seen = $s; + })); + $this->assertSame($stream, $seen); + $this->assertSame(2, $stream->pipe(fn($s) => $s->count())); + } + + public function testRange(): void + { + $this->assertSame([1, 2, 3], StreamCollection::range(1, 3)->all()); + $this->assertSame([0, 5, 10], StreamCollection::range(0, 10, 5)->all()); + $this->assertSame([3, 2, 1], StreamCollection::range(3, 1)->all()); + $this->assertSame([0, 0.5, 1.0], StreamCollection::range(0, 1, 0.5)->all()); + $this->assertSame([5], StreamCollection::range(5, 5)->all()); + } + + public function testRangeIsLazyAndRejectsAZeroStep(): void + { + $this->assertSame([1, 2, 3], StreamCollection::range(1, PHP_INT_MAX - 1)->take(3)->all()); + + $this->expectException(\InvalidArgumentException::class); + StreamCollection::range(1, 5, 0); + } + + public function testTimes(): void + { + $this->assertSame([1, 2, 3], StreamCollection::times(3)->all()); + $this->assertSame([2, 4, 6], StreamCollection::times(3, fn($i) => $i * 2)->all()); + $this->assertSame([], StreamCollection::times(0)->all()); + } + + // ----- files ------------------------------------------------------------------------ + + public function testLinesReadsAFileOneLineAtATime(): void + { + $path = tempnam(sys_get_temp_dir(), 'stream'); + file_put_contents($path, "one\r\ntwo\nthree"); + + try { + $this->assertSame(['one', 'two', 'three'], StreamCollection::lines($path)->all()); + $this->assertSame(['one', 'two'], StreamCollection::lines($path)->take(2)->all()); + $this->assertSame(3, StreamCollection::lines($path)->count()); + $this->assertSame(['TWO'], StreamCollection::lines($path)->filter(fn($l) => $l === 'two')->map(fn($line) => strtoupper($line))->values()->all()); + } finally { + unlink($path); + } + } + + public function testLinesClosesTheFileWhenTheConsumerStopsEarly(): void + { + $path = tempnam(sys_get_temp_dir(), 'stream'); + file_put_contents($path, str_repeat("line\n", 50)); + + try { + $this->assertSame('line', StreamCollection::lines($path)->first()); + gc_collect_cycles(); + + // On Linux, a deleted file whose handle is still open is still listed under /proc. + if (is_dir('/proc/self/fd')) { + $open = array_filter(array_map(fn($fd) => @readlink($fd), glob('/proc/self/fd/*') ?: []), fn($target) => $target === $path); + $this->assertSame([], array_values($open)); + } + } finally { + unlink($path); + } + } + + public function testLinesFailsClearlyForAMissingFile(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('does not exist or is not readable'); + + StreamCollection::lines('/nonexistent/file.log'); + } + + // ----- remember --------------------------------------------------------------------- + + public function testAGeneratorSourceCanOnlyBeReadOnce(): void + { + $stream = new StreamCollection((function () { + yield 1; + yield 2; + })()); + + $stream->all(); + + $this->expectException(\Exception::class); + $stream->all(); + } + + public function testRememberLetsAGeneratorSourceBeReadAgain(): void + { + $stream = (new StreamCollection((function () { + yield 'a' => 1; + yield 'b' => 2; + })()))->remember(); + + $this->assertSame(['a' => 1, 'b' => 2], $stream->all()); + $this->assertSame(['a' => 1, 'b' => 2], $stream->all()); + $this->assertSame(2, $stream->count()); + } + + public function testRememberRunsTheSourceOnlyOnceAndReadsLazily(): void + { + $pulled = []; + $stream = $this->counting(5, $pulled)->remember(); + + $this->assertSame([1, 2], $stream->take(2)->all()); + $this->assertSame([1, 2], $pulled); + + $this->assertSame([1, 2, 3, 4, 5], $stream->all()); + $this->assertSame([1, 2, 3, 4, 5], $pulled, 'items 1 and 2 were not produced again'); + + $stream->all(); + $this->assertSame([1, 2, 3, 4, 5], $pulled); + } + + public function testRememberedStreamsCanBeReadInterleaved(): void + { + $pulled = []; + $stream = $this->counting(3, $pulled)->remember(); + + $a = $stream->getIterator(); + $b = $stream->getIterator(); + $a->rewind(); + $b->rewind(); + + $this->assertSame(1, $a->current()); + $a->next(); + $this->assertSame(2, $a->current()); + $this->assertSame(1, $b->current(), 'a second reader starts from the beginning'); + $b->next(); + $this->assertSame(2, $b->current()); + $this->assertSame([1, 2], $pulled); + } + + public function testMethodsReturnStreamsOfTheSameClass(): void + { + $stream = new StreamCollection([1, 2, 3]); + + foreach ([$stream->map(fn($x) => $x), $stream->filter(), $stream->reject(fn() => false), $stream->take(1), $stream->skip(1), $stream->takeWhile(fn() => true), $stream->skipWhile(fn() => false), $stream->unique(), $stream->flatten(), $stream->values(), $stream->concat([1]), $stream->flatMap(fn($x) => [$x]), $stream->remember(), $stream->pluck('x')] as $result) { + $this->assertInstanceOf(StreamCollection::class, $result); + } + } +} diff --git a/tests/StreamCollectionTest.php b/tests/StreamCollectionTest.php index 7cbca7db..a69eeb26 100644 --- a/tests/StreamCollectionTest.php +++ b/tests/StreamCollectionTest.php @@ -368,7 +368,7 @@ public function testUniqueWithKey() $items = [ ['id' => 1, 'name' => 'Alice'], ['id' => 2, 'name' => 'Bob'], - ['id' => 1, 'name' => 'Alice Duplicate'], // This will overwrite the first one + ['id' => 1, 'name' => 'Alice Duplicate'], // a later duplicate is dropped, as in Collection::unique() ['id' => 3, 'name' => 'Charlie'], ]; @@ -378,7 +378,7 @@ public function testUniqueWithKey() $uniqueArray = $unique->values()->all(); $this->assertCount(3, $uniqueArray); - $this->assertEquals('Alice Duplicate', $uniqueArray[0]['name']); // First item is now the duplicate + $this->assertEquals('Alice', $uniqueArray[0]['name']); // the first of each is kept $this->assertEquals('Bob', $uniqueArray[1]['name']); $this->assertEquals('Charlie', $uniqueArray[2]['name']); }