Inital commit after splitting from project

This commit is contained in:
2026-07-21 22:00:16 +02:00
commit f299f2c60e
49 changed files with 3102 additions and 0 deletions
+62
View File
@@ -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);
}
}
}
}
+63
View File
@@ -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),
};
}
}
+22
View File
@@ -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,
) {}
}
+19
View File
@@ -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';
}
+55
View File
@@ -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;
}
}
+38
View File
@@ -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,
));
}
}
+50
View File
@@ -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;
}
}
+38
View File
@@ -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;
}
}
}
}
+29
View File
@@ -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;
}
}
+29
View File
@@ -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;
}
}
+29
View File
@@ -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;
}
}
+65
View File
@@ -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;
}
}
+34
View File
@@ -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);
}
}
+33
View File
@@ -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];
}
}
+76
View File
@@ -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
View File
@@ -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;
}
}
+37
View File
@@ -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;
}
}
+32
View File
@@ -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;
}
}
+49
View File
@@ -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;
}
}
+50
View File
@@ -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);
}
}
+28
View File
@@ -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
View File
@@ -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;
}
}