Inital commit after splitting from project
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tez\Utils\Arr;
|
||||
|
||||
final class ArrayDiff
|
||||
{
|
||||
/**
|
||||
* Recursively compares two arrays and returns a DiffResult with all differences.
|
||||
* Uses strict equality (1 !== "1"). Paths are reported in dot-notation.
|
||||
*
|
||||
* @param array<mixed> $old
|
||||
* @param array<mixed> $new
|
||||
*/
|
||||
public static function compare(array $old, array $new): DiffResult
|
||||
{
|
||||
$changes = [];
|
||||
self::diff($old, $new, '', $changes);
|
||||
|
||||
return new DiffResult($changes);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<mixed> $old
|
||||
* @param array<mixed> $new
|
||||
* @param list<Change> $changes
|
||||
*/
|
||||
private static function diff(array $old, array $new, string $prefix, array &$changes): void
|
||||
{
|
||||
$allKeys = array_unique(array_merge(array_keys($old), array_keys($new)));
|
||||
|
||||
foreach ($allKeys as $key) {
|
||||
$path = $prefix !== '' ? $prefix . '.' . $key : (string) $key;
|
||||
|
||||
$inOld = array_key_exists($key, $old);
|
||||
$inNew = array_key_exists($key, $new);
|
||||
|
||||
if ($inOld && !$inNew) {
|
||||
$changes[] = new Change($path, ChangeType::Removed, $old[$key], null);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!$inOld && $inNew) {
|
||||
$changes[] = new Change($path, ChangeType::Added, null, $new[$key]);
|
||||
continue;
|
||||
}
|
||||
|
||||
$oldVal = $old[$key];
|
||||
$newVal = $new[$key];
|
||||
|
||||
if (is_array($oldVal) && is_array($newVal)) {
|
||||
self::diff($oldVal, $newVal, $path, $changes);
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($oldVal !== $newVal) {
|
||||
$changes[] = new Change($path, ChangeType::Changed, $oldVal, $newVal);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tez\Utils\Arr;
|
||||
|
||||
/**
|
||||
* Computes the Cartesian product of any number of sets lazily via a Generator.
|
||||
*
|
||||
* Each yielded value is a tuple (list) containing one element from every input set.
|
||||
* The number of tuples equals the product of all set sizes.
|
||||
* Associative keys in input sets are stripped; only values are used.
|
||||
*/
|
||||
class CartesianProduct
|
||||
{
|
||||
/**
|
||||
* Yields every combination of one element from each provided set.
|
||||
* The result is computed lazily — no intermediate array is built.
|
||||
* Passing no sets yields a single empty tuple.
|
||||
*
|
||||
* @param iterable<mixed> ...$sets
|
||||
*/
|
||||
public static function create(iterable ...$sets): \Generator
|
||||
{
|
||||
if (empty($sets)) {
|
||||
yield [];
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$sets = array_map(fn($set) => self::toIndexedArray($set), $sets);
|
||||
$counts = array_map('count', $sets);
|
||||
$total = array_product($counts);
|
||||
|
||||
for ($i = 0; $i < $total; $i++) {
|
||||
$tuple = [];
|
||||
$divisor = 1;
|
||||
|
||||
// Mixed-radix index decomposition: walk sets right-to-left so that the
|
||||
// rightmost set cycles fastest, matching standard Cartesian-product order.
|
||||
for ($j = count($sets) - 1; $j >= 0; $j--) {
|
||||
$index = (int) ($i / $divisor) % $counts[$j];
|
||||
$tuple[] = $sets[$j][$index];
|
||||
$divisor *= $counts[$j];
|
||||
}
|
||||
|
||||
yield array_reverse($tuple);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param iterable<mixed> $set
|
||||
* @return list<mixed>
|
||||
*/
|
||||
private static function toIndexedArray(iterable $set): array
|
||||
{
|
||||
return match (true) {
|
||||
is_array($set) => array_values($set),
|
||||
default => iterator_to_array($set, false),
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tez\Utils\Arr;
|
||||
|
||||
/**
|
||||
* Represents a single difference between two arrays at a specific dot-notation path.
|
||||
*/
|
||||
final readonly class Change
|
||||
{
|
||||
public function __construct(
|
||||
/** Dot-notation path to the changed key, e.g. "user.address.city". */
|
||||
public readonly string $path,
|
||||
/** Whether the key was added, removed, or its value changed. */
|
||||
public readonly ChangeType $type,
|
||||
/** The value in the old array, or null when the key was added. */
|
||||
public readonly mixed $old,
|
||||
/** The value in the new array, or null when the key was removed. */
|
||||
public readonly mixed $new,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tez\Utils\Arr;
|
||||
|
||||
use Tez\Utils\Enum\BackedEnumTrait;
|
||||
|
||||
/**
|
||||
* Classifies the type of a single difference detected by ArrayDiff.
|
||||
*/
|
||||
enum ChangeType: string
|
||||
{
|
||||
use BackedEnumTrait;
|
||||
|
||||
case Added = 'added';
|
||||
case Removed = 'removed';
|
||||
case Changed = 'changed';
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tez\Utils\Arr;
|
||||
|
||||
final class DeepMerge
|
||||
{
|
||||
/**
|
||||
* Recursively merges one or more override arrays into a base array.
|
||||
* Overrides are applied left to right.
|
||||
*
|
||||
* Merge rules:
|
||||
* - String key exists only in base → kept as-is
|
||||
* - String key exists only in override → added
|
||||
* - Both values are arrays → merged recursively
|
||||
* - Override value is scalar or null → overwrites base value
|
||||
* - Integer keys → appended (like array_merge)
|
||||
*
|
||||
* @param array<mixed> $base
|
||||
* @param array<mixed> ...$overrides
|
||||
* @return array<mixed>
|
||||
*/
|
||||
public static function merge(array $base, array ...$overrides): array
|
||||
{
|
||||
foreach ($overrides as $override) {
|
||||
$base = self::mergeTwo($base, $override);
|
||||
}
|
||||
|
||||
return $base;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<mixed> $base
|
||||
* @param array<mixed> $override
|
||||
* @return array<mixed>
|
||||
*/
|
||||
private static function mergeTwo(array $base, array $override): array
|
||||
{
|
||||
foreach ($override as $key => $value) {
|
||||
if (is_int($key)) {
|
||||
// Integer keys are appended, matching array_merge behaviour.
|
||||
$base[] = $value;
|
||||
} elseif (is_array($value) && isset($base[$key]) && is_array($base[$key])) {
|
||||
// Both sides are arrays — recurse.
|
||||
$base[$key] = self::mergeTwo($base[$key], $value);
|
||||
} else {
|
||||
// Scalar, null, or type mismatch — right side wins.
|
||||
$base[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $base;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tez\Utils\Arr;
|
||||
|
||||
/**
|
||||
* Immutable result of an ArrayDiff comparison.
|
||||
*/
|
||||
final readonly class DiffResult
|
||||
{
|
||||
/**
|
||||
* @param list<Change> $changes
|
||||
*/
|
||||
public function __construct(
|
||||
private array $changes,
|
||||
) {}
|
||||
|
||||
public function hasChanges(): bool
|
||||
{
|
||||
return $this->changes !== [];
|
||||
}
|
||||
|
||||
/** @return list<Change> */
|
||||
public function changes(): array
|
||||
{
|
||||
return $this->changes;
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
public function paths(): array
|
||||
{
|
||||
return array_values(array_map(
|
||||
static fn(Change $c): string => $c->path,
|
||||
$this->changes,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tez\Utils\Arr;
|
||||
|
||||
final class Find
|
||||
{
|
||||
/**
|
||||
* Returns the first item for which $callback returns true, or null if none matches.
|
||||
*
|
||||
* @template TKey of array-key
|
||||
* @template TValue
|
||||
* @param array<TKey, TValue> $items
|
||||
* @param callable(TValue): bool $callback
|
||||
* @return TValue|null
|
||||
*/
|
||||
public static function first(array $items, callable $callback): mixed
|
||||
{
|
||||
foreach ($items as $item) {
|
||||
if ($callback($item)) {
|
||||
return $item;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the last item for which $callback returns true, or null if none matches.
|
||||
*
|
||||
* @template TKey of array-key
|
||||
* @template TValue
|
||||
* @param array<TKey, TValue> $items
|
||||
* @param callable(TValue): bool $callback
|
||||
* @return TValue|null
|
||||
*/
|
||||
public static function last(array $items, callable $callback): mixed
|
||||
{
|
||||
$match = null;
|
||||
|
||||
foreach ($items as $item) {
|
||||
if ($callback($item)) {
|
||||
$match = $item;
|
||||
}
|
||||
}
|
||||
|
||||
return $match;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tez\Utils\Arr;
|
||||
|
||||
final class Flatten
|
||||
{
|
||||
/**
|
||||
* Recursively flattens a nested array into a single flat list.
|
||||
* Associative keys in nested arrays are discarded.
|
||||
*
|
||||
* @param array<mixed> $array
|
||||
* @return list<mixed>
|
||||
*/
|
||||
public static function flatten(array $array, int $depth = PHP_INT_MAX): array
|
||||
{
|
||||
$result = [];
|
||||
self::flattenInto($array, $depth, $result);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<mixed> $array
|
||||
* @param list<mixed> $result
|
||||
*/
|
||||
private static function flattenInto(array $array, int $depth, array &$result): void
|
||||
{
|
||||
foreach ($array as $item) {
|
||||
if (is_array($item) && $depth > 0) {
|
||||
self::flattenInto($item, $depth - 1, $result);
|
||||
} else {
|
||||
$result[] = $item;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tez\Utils\Arr;
|
||||
|
||||
final class GroupBy
|
||||
{
|
||||
/**
|
||||
* Groups the items of an array by the key returned from a callback.
|
||||
*
|
||||
* @template TKey of int|string
|
||||
* @template TValue
|
||||
* @param array<TValue> $items
|
||||
* @param callable(TValue): TKey $keyFn
|
||||
* @return array<TKey, list<TValue>>
|
||||
*/
|
||||
public static function group(array $items, callable $keyFn): array
|
||||
{
|
||||
$result = [];
|
||||
|
||||
foreach ($items as $item) {
|
||||
$key = $keyFn($item);
|
||||
$result[$key][] = $item;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tez\Utils\Arr;
|
||||
|
||||
final class KeyBy
|
||||
{
|
||||
/**
|
||||
* Builds an associative lookup map by indexing each item under the key
|
||||
* returned from the callback. If two items produce the same key, the last wins.
|
||||
*
|
||||
* @template TKey of int|string
|
||||
* @template TValue
|
||||
* @param array<TValue> $items
|
||||
* @param callable(TValue): TKey $keyFn
|
||||
* @return array<TKey, TValue>
|
||||
*/
|
||||
public static function index(array $items, callable $keyFn): array
|
||||
{
|
||||
$result = [];
|
||||
|
||||
foreach ($items as $item) {
|
||||
$result[$keyFn($item)] = $item;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tez\Utils\Arr;
|
||||
|
||||
final class MapWithKeys
|
||||
{
|
||||
/**
|
||||
* Maps over an array where the callback returns a new [key => value] pair.
|
||||
* If two items produce the same key, the last one wins.
|
||||
*
|
||||
* @param array<mixed> $items
|
||||
* @param callable(mixed, int|string): array<int|string, mixed> $fn
|
||||
* @return array<int|string, mixed>
|
||||
*/
|
||||
public static function map(array $items, callable $fn): array
|
||||
{
|
||||
$result = [];
|
||||
|
||||
foreach ($items as $key => $value) {
|
||||
foreach ($fn($value, $key) as $newKey => $newValue) {
|
||||
$result[$newKey] = $newValue;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tez\Utils\Arr;
|
||||
|
||||
/**
|
||||
* Immutable result of a paginated array slice.
|
||||
*
|
||||
* @template T
|
||||
*/
|
||||
final readonly class Page
|
||||
{
|
||||
/**
|
||||
* @param list<T> $items
|
||||
*/
|
||||
public function __construct(
|
||||
private array $items,
|
||||
private int $total,
|
||||
private int $currentPage,
|
||||
private int $perPage,
|
||||
) {}
|
||||
|
||||
/** @return list<T> The items on the current page. */
|
||||
public function items(): array
|
||||
{
|
||||
return $this->items;
|
||||
}
|
||||
|
||||
/** Returns the total number of items across all pages. */
|
||||
public function total(): int
|
||||
{
|
||||
return $this->total;
|
||||
}
|
||||
|
||||
/** Returns the current page number (1-based). */
|
||||
public function currentPage(): int
|
||||
{
|
||||
return $this->currentPage;
|
||||
}
|
||||
|
||||
/** Returns the number of items per page. */
|
||||
public function perPage(): int
|
||||
{
|
||||
return $this->perPage;
|
||||
}
|
||||
|
||||
/** Returns the last page number. Always at least 1, even for empty sets. */
|
||||
public function lastPage(): int
|
||||
{
|
||||
return max(1, (int) ceil($this->total / $this->perPage));
|
||||
}
|
||||
|
||||
/** Returns true if a next page exists. */
|
||||
public function hasNext(): bool
|
||||
{
|
||||
return $this->currentPage < $this->lastPage();
|
||||
}
|
||||
|
||||
/** Returns true if a previous page exists (i.e. current page > 1). */
|
||||
public function hasPrev(): bool
|
||||
{
|
||||
return $this->currentPage > 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tez\Utils\Arr;
|
||||
|
||||
final class Paginator
|
||||
{
|
||||
/**
|
||||
* Paginates an array and returns an immutable Page with slice and metadata.
|
||||
*
|
||||
* @template T
|
||||
* @param list<T> $items
|
||||
*
|
||||
* @throws \InvalidArgumentException if $page < 1 or $perPage < 1
|
||||
* @return Page<T>
|
||||
*/
|
||||
public static function paginate(array $items, int $page, int $perPage): Page
|
||||
{
|
||||
if ($page < 1) {
|
||||
throw new \InvalidArgumentException('Page must be >= 1, got ' . $page . '.');
|
||||
}
|
||||
|
||||
if ($perPage < 1) {
|
||||
throw new \InvalidArgumentException('Per-page must be >= 1, got ' . $perPage . '.');
|
||||
}
|
||||
|
||||
$total = count($items);
|
||||
$offset = ($page - 1) * $perPage;
|
||||
$slice = array_values(array_slice($items, $offset, $perPage));
|
||||
|
||||
return new Page($slice, $total, $page, $perPage);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tez\Utils\Arr;
|
||||
|
||||
final class Partition
|
||||
{
|
||||
/**
|
||||
* Splits an array into two lists in a single pass.
|
||||
* Returns [passing, failing] — items satisfying the predicate come first.
|
||||
*
|
||||
* @template TValue
|
||||
* @param array<TValue> $items
|
||||
* @param callable(TValue): bool $predicate
|
||||
* @return array{list<TValue>, list<TValue>}
|
||||
*/
|
||||
public static function by(array $items, callable $predicate): array
|
||||
{
|
||||
$pass = [];
|
||||
$fail = [];
|
||||
|
||||
foreach ($items as $item) {
|
||||
if ($predicate($item)) {
|
||||
$pass[] = $item;
|
||||
} else {
|
||||
$fail[] = $item;
|
||||
}
|
||||
}
|
||||
|
||||
return [$pass, $fail];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tez\Utils\Arr;
|
||||
|
||||
final class Pluck
|
||||
{
|
||||
/**
|
||||
* Extracts the values of a single dot-notation key from an array of records.
|
||||
* Items where the key is absent are skipped.
|
||||
*
|
||||
* @param array<array<mixed>> $items
|
||||
* @return list<mixed>
|
||||
*/
|
||||
public static function values(array $items, string $key): array
|
||||
{
|
||||
$result = [];
|
||||
|
||||
foreach ($items as $item) {
|
||||
[$found, $value] = self::resolve($item, $key);
|
||||
|
||||
if ($found) {
|
||||
$result[] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an associative array indexed by $indexKey, with $valueKey as the value.
|
||||
* Items where either key is absent are skipped.
|
||||
*
|
||||
* @param array<array<mixed>> $items
|
||||
* @return array<int|string, mixed>
|
||||
*/
|
||||
public static function keyedBy(array $items, string $valueKey, string $indexKey): array
|
||||
{
|
||||
$result = [];
|
||||
|
||||
foreach ($items as $item) {
|
||||
[$foundIdx, $idx] = self::resolve($item, $indexKey);
|
||||
[$foundVal, $value] = self::resolve($item, $valueKey);
|
||||
|
||||
if ($foundIdx && $foundVal && (is_int($idx) || is_string($idx))) {
|
||||
$result[$idx] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a dot-notation key against an array.
|
||||
* Returns [true, $value] on success, [false, null] when any segment is missing.
|
||||
*
|
||||
* @param array<mixed> $data
|
||||
* @return array{bool, mixed}
|
||||
*/
|
||||
private static function resolve(array $data, string $key): array
|
||||
{
|
||||
$parts = explode('.', $key);
|
||||
$current = $data;
|
||||
|
||||
foreach ($parts as $part) {
|
||||
if (!is_array($current) || !array_key_exists($part, $current)) {
|
||||
return [false, null];
|
||||
}
|
||||
|
||||
$current = $current[$part];
|
||||
}
|
||||
|
||||
return [true, $current];
|
||||
}
|
||||
}
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tez\Utils\Arr;
|
||||
|
||||
/**
|
||||
* Safely read deeply nested values from an array using dot-notation paths.
|
||||
*
|
||||
* Each segment of the path is resolved in turn. Integer-looking segments
|
||||
* (e.g. "0") address list indices. When any segment is missing or has the
|
||||
* wrong type, the method returns $default.
|
||||
*
|
||||
* Example path: 'chart.result.0.meta.currency'
|
||||
*/
|
||||
final class SafeGet
|
||||
{
|
||||
/**
|
||||
* Read a nested value as a string.
|
||||
*
|
||||
* @param array<mixed> $data
|
||||
*/
|
||||
public static function string(array $data, string $path, ?string $default = null): ?string
|
||||
{
|
||||
$value = self::resolve($data, $path);
|
||||
|
||||
if (!is_string($value)) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a nested value as an integer.
|
||||
*
|
||||
* @param array<mixed> $data
|
||||
*/
|
||||
public static function int(array $data, string $path, ?int $default = null): ?int
|
||||
{
|
||||
$value = self::resolve($data, $path);
|
||||
|
||||
if (!is_int($value)) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a nested value as a float.
|
||||
* Integer values are widened to float automatically.
|
||||
*
|
||||
* @param array<mixed> $data
|
||||
*/
|
||||
public static function float(array $data, string $path, ?float $default = null): ?float
|
||||
{
|
||||
$value = self::resolve($data, $path);
|
||||
|
||||
if (is_float($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (is_int($value)) {
|
||||
return (float) $value;
|
||||
}
|
||||
|
||||
return $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a nested value as a bool.
|
||||
*
|
||||
* @param array<mixed> $data
|
||||
*/
|
||||
public static function bool(array $data, string $path, ?bool $default = null): ?bool
|
||||
{
|
||||
$value = self::resolve($data, $path);
|
||||
|
||||
if (!is_bool($value)) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a nested value as an array.
|
||||
*
|
||||
* @param array<mixed> $data
|
||||
* @param array<mixed>|null $default
|
||||
* @return array<mixed>|null
|
||||
*/
|
||||
public static function array(array $data, string $path, ?array $default = null): ?array
|
||||
{
|
||||
$value = self::resolve($data, $path);
|
||||
|
||||
if (!is_array($value)) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the dot-notation path and return the raw value, or null when missing.
|
||||
*
|
||||
* @param array<mixed> $data
|
||||
*/
|
||||
private static function resolve(array $data, string $path): mixed
|
||||
{
|
||||
$segments = explode('.', $path);
|
||||
$current = $data;
|
||||
|
||||
foreach ($segments as $segment) {
|
||||
if (!is_array($current)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$key = is_numeric($segment) && ctype_digit($segment)
|
||||
? (int) $segment
|
||||
: $segment;
|
||||
|
||||
if (!array_key_exists($key, $current)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$current = $current[$key];
|
||||
}
|
||||
|
||||
return $current;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tez\Utils\Arr;
|
||||
|
||||
final class Sliding
|
||||
{
|
||||
/**
|
||||
* Produces consecutive overlapping sub-arrays (windows) of a fixed size.
|
||||
* The $step parameter controls how many positions to advance between windows.
|
||||
*
|
||||
* @param array<mixed> $items
|
||||
* @throws \InvalidArgumentException when $size or $step is less than 1
|
||||
* @return list<list<mixed>>
|
||||
*/
|
||||
public static function window(array $items, int $size, int $step = 1): array
|
||||
{
|
||||
if ($size < 1) {
|
||||
throw new \InvalidArgumentException('Window size must be >= 1.');
|
||||
}
|
||||
|
||||
if ($step < 1) {
|
||||
throw new \InvalidArgumentException('Step must be >= 1.');
|
||||
}
|
||||
|
||||
$items = array_values($items);
|
||||
$count = count($items);
|
||||
$result = [];
|
||||
|
||||
for ($i = 0; $i + $size <= $count; $i += $step) {
|
||||
$result[] = array_slice($items, $i, $size);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tez\Utils\Arr;
|
||||
|
||||
final class SortBy
|
||||
{
|
||||
/**
|
||||
* Sorts a copy of the array by the value extracted via callback.
|
||||
* Does not mutate the original array. Result is always re-indexed.
|
||||
*
|
||||
* @template TValue
|
||||
* @param array<TValue> $items
|
||||
* @param callable(TValue): (int|float|string) $keyFn
|
||||
* @return list<TValue>
|
||||
*/
|
||||
public static function sort(array $items, callable $keyFn, bool $descending = false): array
|
||||
{
|
||||
$copy = array_values($items);
|
||||
|
||||
usort($copy, static function ($a, $b) use ($keyFn, $descending): int {
|
||||
$ka = $keyFn($a);
|
||||
$kb = $keyFn($b);
|
||||
$cmp = is_string($ka) ? strcmp((string) $ka, (string) $kb) : ($ka <=> $kb);
|
||||
|
||||
return $descending ? -$cmp : $cmp;
|
||||
});
|
||||
|
||||
return $copy;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tez\Utils\Arr;
|
||||
|
||||
final class Transpose
|
||||
{
|
||||
/**
|
||||
* Swaps rows and columns of a 2D array.
|
||||
* All rows must have the same number of columns.
|
||||
* Associative keys are stripped; result is always a list of lists.
|
||||
*
|
||||
* @param array<array<mixed>> $rows
|
||||
* @throws \InvalidArgumentException when rows have unequal column counts
|
||||
* @return list<list<mixed>>
|
||||
*/
|
||||
public static function matrix(array $rows): array
|
||||
{
|
||||
if ($rows === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = array_values(array_map('array_values', $rows));
|
||||
$colCount = count($rows[0]);
|
||||
|
||||
foreach ($rows as $row) {
|
||||
if (count($row) !== $colCount) {
|
||||
throw new \InvalidArgumentException(
|
||||
'All rows must have the same number of columns.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$result = [];
|
||||
|
||||
for ($col = 0; $col < $colCount; $col++) {
|
||||
$column = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$column[] = $row[$col];
|
||||
}
|
||||
|
||||
$result[] = $column;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tez\Utils\Arr;
|
||||
|
||||
final class UniqueBy
|
||||
{
|
||||
/**
|
||||
* Returns an array with duplicates removed, where uniqueness is determined by the callback.
|
||||
*
|
||||
* By default the first occurrence of each key wins and keys are re-indexed.
|
||||
* Set $lastWins = true to keep the last occurrence instead.
|
||||
* Set $preserveKeys = true to retain the original array keys.
|
||||
*
|
||||
* @template TKey of array-key
|
||||
* @template TValue
|
||||
* @param array<TKey, TValue> $items
|
||||
* @param callable(TValue): (string|int) $callback
|
||||
* @return ($preserveKeys is true ? array<TKey, TValue> : list<TValue>)
|
||||
*/
|
||||
public static function filter(
|
||||
array $items,
|
||||
callable $callback,
|
||||
bool $lastWins = false,
|
||||
bool $preserveKeys = false,
|
||||
): array {
|
||||
if ($lastWins) {
|
||||
$items = array_reverse($items, preserve_keys: true);
|
||||
}
|
||||
|
||||
$seen = [];
|
||||
$result = [];
|
||||
|
||||
foreach ($items as $key => $item) {
|
||||
$uniqueKey = $callback($item);
|
||||
|
||||
if (!isset($seen[$uniqueKey])) {
|
||||
$seen[$uniqueKey] = true;
|
||||
$result[$key] = $item;
|
||||
}
|
||||
}
|
||||
|
||||
if ($lastWins) {
|
||||
$result = array_reverse($result, preserve_keys: true);
|
||||
}
|
||||
|
||||
return $preserveKeys ? $result : array_values($result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tez\Utils\Arr;
|
||||
|
||||
final class Wrap
|
||||
{
|
||||
/**
|
||||
* Ensures the given value is wrapped in an array.
|
||||
* null becomes [], an existing array is returned as-is,
|
||||
* any other value is wrapped in a single-element array.
|
||||
*
|
||||
* @return array<mixed>
|
||||
*/
|
||||
public static function ensure(mixed $value): array
|
||||
{
|
||||
if ($value === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (is_array($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return [$value];
|
||||
}
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tez\Utils\Arr;
|
||||
|
||||
final class Zip
|
||||
{
|
||||
/**
|
||||
* Combines multiple arrays element-by-element into a list of tuples.
|
||||
* Shorter arrays are padded with null to match the longest input.
|
||||
*
|
||||
* @param array<mixed> ...$arrays
|
||||
* @return list<list<mixed>>
|
||||
*/
|
||||
public static function zip(array ...$arrays): array
|
||||
{
|
||||
if ($arrays === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$arrays = array_map('array_values', $arrays);
|
||||
$maxLen = (int) max(array_map('count', $arrays));
|
||||
$result = [];
|
||||
|
||||
for ($i = 0; $i < $maxLen; $i++) {
|
||||
$tuple = [];
|
||||
|
||||
foreach ($arrays as $arr) {
|
||||
$tuple[] = $arr[$i] ?? null;
|
||||
}
|
||||
|
||||
$result[] = $tuple;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Combines multiple arrays element-by-element, stopping at the shortest input.
|
||||
*
|
||||
* @param array<mixed> ...$arrays
|
||||
* @return list<list<mixed>>
|
||||
*/
|
||||
public static function shortest(array ...$arrays): array
|
||||
{
|
||||
if ($arrays === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$arrays = array_map('array_values', $arrays);
|
||||
$minLen = (int) min(array_map('count', $arrays));
|
||||
$result = [];
|
||||
|
||||
for ($i = 0; $i < $minLen; $i++) {
|
||||
$tuple = [];
|
||||
|
||||
foreach ($arrays as $arr) {
|
||||
$tuple[] = $arr[$i];
|
||||
}
|
||||
|
||||
$result[] = $tuple;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user