Inital commit after splitting from project
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
branches: [master]
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.3'
|
||||
coverage: none
|
||||
- name: Install dependencies
|
||||
run: composer install --prefer-dist --no-progress
|
||||
- name: PHPStan
|
||||
run: composer phpstan
|
||||
- name: Code style check
|
||||
run: composer cs-check
|
||||
- name: Tests
|
||||
run: composer test
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
# ---> Symfony
|
||||
# Cache and logs (Symfony2)
|
||||
/app/cache/*
|
||||
/app/logs/*
|
||||
!app/cache/.gitkeep
|
||||
!app/logs/.gitkeep
|
||||
|
||||
# Email spool folder
|
||||
/app/spool/*
|
||||
|
||||
# Cache, session files and logs (Symfony3)
|
||||
/var/cache/*
|
||||
/var/logs/*
|
||||
/var/sessions/*
|
||||
!var/cache/.gitkeep
|
||||
!var/logs/.gitkeep
|
||||
!var/sessions/.gitkeep
|
||||
|
||||
# Logs (Symfony4)
|
||||
/var/log/*
|
||||
!var/log/.gitkeep
|
||||
|
||||
# Parameters
|
||||
/app/config/parameters.yml
|
||||
/app/config/parameters.ini
|
||||
|
||||
# Managed by Composer
|
||||
/app/bootstrap.php.cache
|
||||
/var/bootstrap.php.cache
|
||||
/bin/*
|
||||
!bin/console
|
||||
!bin/symfony_requirements
|
||||
/vendor/
|
||||
|
||||
# Assets and user uploads
|
||||
/web/bundles/
|
||||
/web/uploads/
|
||||
|
||||
# PHPUnit
|
||||
/app/phpunit.xml
|
||||
/phpunit.xml
|
||||
|
||||
# Build data
|
||||
/build/
|
||||
|
||||
# Composer PHAR
|
||||
/composer.phar
|
||||
|
||||
# Backup entities generated with doctrine:generate:entities command
|
||||
**/Entity/*~
|
||||
|
||||
# Embedded web-server pid file
|
||||
/.web-server-pid
|
||||
|
||||
# ---> JetBrains
|
||||
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
|
||||
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
|
||||
|
||||
# User-specific stuff
|
||||
.idea/**/workspace.xml
|
||||
.idea/**/tasks.xml
|
||||
.idea/**/usage.statistics.xml
|
||||
.idea/**/dictionaries
|
||||
.idea/**/shelf
|
||||
|
||||
# AWS User-specific
|
||||
.idea/**/aws.xml
|
||||
|
||||
# Generated files
|
||||
.idea/**/contentModel.xml
|
||||
|
||||
# Sensitive or high-churn files
|
||||
.idea/**/dataSources/
|
||||
.idea/**/dataSources.ids
|
||||
.idea/**/dataSources.local.xml
|
||||
.idea/**/sqlDataSources.xml
|
||||
.idea/**/dynamic.xml
|
||||
.idea/**/uiDesigner.xml
|
||||
.idea/**/dbnavigator.xml
|
||||
|
||||
# Gradle
|
||||
.idea/**/gradle.xml
|
||||
.idea/**/libraries
|
||||
|
||||
# Gradle and Maven with auto-import
|
||||
# When using Gradle or Maven with auto-import, you should exclude module files,
|
||||
# since they will be recreated, and may cause churn. Uncomment if using
|
||||
# auto-import.
|
||||
# .idea/artifacts
|
||||
# .idea/compiler.xml
|
||||
# .idea/jarRepositories.xml
|
||||
# .idea/modules.xml
|
||||
# .idea/*.iml
|
||||
# .idea/modules
|
||||
# *.iml
|
||||
# *.ipr
|
||||
|
||||
# CMake
|
||||
cmake-build-*/
|
||||
|
||||
# Mongo Explorer plugin
|
||||
.idea/**/mongoSettings.xml
|
||||
|
||||
# File-based project format
|
||||
*.iws
|
||||
|
||||
# IntelliJ
|
||||
out/
|
||||
|
||||
# mpeltonen/sbt-idea plugin
|
||||
.idea_modules/
|
||||
|
||||
# JIRA plugin
|
||||
atlassian-ide-plugin.xml
|
||||
|
||||
# Cursive Clojure plugin
|
||||
.idea/replstate.xml
|
||||
|
||||
# SonarLint plugin
|
||||
.idea/sonarlint/
|
||||
|
||||
# Crashlytics plugin (for Android Studio and IntelliJ)
|
||||
com_crashlytics_export_strings.xml
|
||||
crashlytics.properties
|
||||
crashlytics-build.properties
|
||||
fabric.properties
|
||||
|
||||
# Editor-based Rest Client
|
||||
.idea/httpRequests
|
||||
|
||||
# Android studio 3.1+ serialized cache file
|
||||
.idea/caches/build_file_checksums.ser
|
||||
|
||||
# ---> macOS
|
||||
# General
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
|
||||
# Icon must end with two \r
|
||||
Icon
|
||||
|
||||
# Thumbnails
|
||||
._*
|
||||
|
||||
# Files that might appear in the root of a volume
|
||||
.DocumentRevisions-V100
|
||||
.fseventsd
|
||||
.Spotlight-V100
|
||||
.TemporaryItems
|
||||
.Trashes
|
||||
.VolumeIcon.icns
|
||||
.com.apple.timemachine.donotpresent
|
||||
|
||||
# Directories potentially created on remote AFP share
|
||||
.AppleDB
|
||||
.AppleDesktop
|
||||
Network Trash Folder
|
||||
Temporary Items
|
||||
.apdisk
|
||||
|
||||
# ---> php-cs-fixer
|
||||
.php-cs-fixer.cache
|
||||
|
||||
# ---> VisualStudioCode
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
!.vscode/*.code-snippets
|
||||
|
||||
# Local History for Visual Studio Code
|
||||
.history/
|
||||
|
||||
# Built Visual Studio Code Extensions
|
||||
*.vsix
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use PhpCsFixer\Config;
|
||||
use PhpCsFixer\Finder;
|
||||
|
||||
$finder = Finder::create()
|
||||
->in([
|
||||
__DIR__ . '/src',
|
||||
__DIR__ . '/tests',
|
||||
])
|
||||
->name('*.php')
|
||||
->notName('*.blade.php')
|
||||
->ignoreDotFiles(true)
|
||||
->ignoreVCS(true);
|
||||
|
||||
return (new Config())
|
||||
->setRiskyAllowed(true)
|
||||
->setRules([
|
||||
'@PER-CS2.0' => true,
|
||||
'@PHP83Migration' => true,
|
||||
|
||||
// Imports
|
||||
'ordered_imports' => ['sort_algorithm' => 'alpha'],
|
||||
'no_unused_imports' => true,
|
||||
'global_namespace_import' => ['import_classes' => false, 'import_constants' => false, 'import_functions' => false],
|
||||
|
||||
// Strings
|
||||
'single_quote' => true,
|
||||
'explicit_string_variable' => true,
|
||||
|
||||
// Arrays
|
||||
'array_syntax' => ['syntax' => 'short'],
|
||||
'trim_array_spaces' => true,
|
||||
'no_whitespace_before_comma_in_array' => true,
|
||||
'whitespace_after_comma_in_array' => ['ensure_single_space' => true],
|
||||
|
||||
// Types / strictness
|
||||
'declare_strict_types' => true,
|
||||
'strict_param' => true,
|
||||
'strict_comparison' => true,
|
||||
|
||||
// PHPDoc
|
||||
'phpdoc_align' => ['align' => 'left'],
|
||||
'phpdoc_order' => true,
|
||||
'phpdoc_trim' => true,
|
||||
'phpdoc_scalar' => true,
|
||||
'no_superfluous_phpdoc_tags' => ['remove_inheritdoc' => true],
|
||||
|
||||
// Control structures
|
||||
'yoda_style' => false,
|
||||
'no_useless_else' => true,
|
||||
'no_useless_return' => true,
|
||||
|
||||
// Misc
|
||||
'concat_space' => ['spacing' => 'one'],
|
||||
'trailing_comma_in_multiline' => ['elements' => ['arrays', 'arguments', 'parameters']],
|
||||
'blank_line_before_statement' => ['statements' => ['return', 'throw', 'try', 'if', 'foreach', 'for', 'while']],
|
||||
])
|
||||
->setFinder($finder);
|
||||
@@ -0,0 +1,7 @@
|
||||
FROM php:8.3-cli-alpine
|
||||
|
||||
RUN apk add --no-cache git unzip
|
||||
|
||||
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
||||
|
||||
WORKDIR /app
|
||||
@@ -0,0 +1,38 @@
|
||||
PHP = docker compose run --rm php
|
||||
|
||||
.PHONY: install test phpstan cs-fix cs-check audit shell build
|
||||
|
||||
## Build the Docker image
|
||||
build:
|
||||
docker compose build
|
||||
|
||||
## Install Composer dependencies
|
||||
install:
|
||||
$(PHP) composer install
|
||||
|
||||
## Run unit tests
|
||||
test:
|
||||
$(PHP) vendor/bin/phpunit --testsuite Unit --colors=always
|
||||
|
||||
## Run PHPStan static analysis
|
||||
phpstan:
|
||||
$(PHP) vendor/bin/phpstan analyse --no-progress --ansi --memory-limit=512M
|
||||
|
||||
## Auto-fix code style
|
||||
cs-fix:
|
||||
$(PHP) vendor/bin/php-cs-fixer fix --ansi
|
||||
|
||||
## Check code style (dry-run)
|
||||
cs-check:
|
||||
$(PHP) vendor/bin/php-cs-fixer fix --dry-run --diff --ansi
|
||||
|
||||
## Check for known security vulnerabilities in dependencies
|
||||
audit:
|
||||
$(PHP) composer audit
|
||||
|
||||
## Run all checks (same as CI)
|
||||
ci: audit phpstan cs-check test
|
||||
|
||||
## Open a shell in the PHP container
|
||||
shell:
|
||||
docker compose run --rm php sh
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "tez/utils-arr",
|
||||
"type": "library",
|
||||
"description": "Array utility helpers",
|
||||
"license": "MIT",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Tez\\Utils\\Arr\\": "src/"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"Tez\\Utils\\Tests\\Arr\\": "tests/"
|
||||
}
|
||||
},
|
||||
"repositories": [
|
||||
{
|
||||
"type": "git",
|
||||
"url": "https://gitea.root-zone.info/Development/tez-utils-enum.git",
|
||||
"priority": 100
|
||||
},
|
||||
{
|
||||
"type": "git",
|
||||
"url": "https://github.com/tezmanian/tez-utils-enum.git",
|
||||
"priority": 50
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"php": ">=8.3",
|
||||
"tez/utils-enum": "^1.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^11.0",
|
||||
"jetbrains/phpstorm-attributes": "^1.0",
|
||||
"phpstan/phpstan": "^2.0",
|
||||
"friendsofphp/php-cs-fixer": "^3.0"
|
||||
},
|
||||
"scripts": {
|
||||
"cs-fix": "php-cs-fixer fix",
|
||||
"cs-check": "php-cs-fixer fix --dry-run --diff",
|
||||
"phpstan": "phpstan analyse",
|
||||
"test": "phpunit --testsuite Unit --colors=always"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
services:
|
||||
php:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
volumes:
|
||||
- .:/app
|
||||
- ../tez-utils-enum:/packages/tez-utils-enum
|
||||
- composer-cache:/tmp/composer
|
||||
user: "${UID:-1000}:${GID:-1000}"
|
||||
environment:
|
||||
COMPOSER_HOME: /tmp/composer
|
||||
|
||||
volumes:
|
||||
composer-cache:
|
||||
@@ -0,0 +1,8 @@
|
||||
parameters:
|
||||
level: 9
|
||||
paths:
|
||||
- src
|
||||
- tests
|
||||
treatPhpDocTypesAsCertain: false
|
||||
parallel:
|
||||
maximumNumberOfProcesses: 1
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||
bootstrap="vendor/autoload.php"
|
||||
colors="true">
|
||||
<testsuites>
|
||||
<testsuite name="Unit">
|
||||
<directory>tests</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
<source>
|
||||
<include>
|
||||
<directory suffix=".php">src</directory>
|
||||
</include>
|
||||
</source>
|
||||
</phpunit>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tez\Utils\Tests\Arr;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Tez\Utils\Arr\ArrayDiff;
|
||||
use Tez\Utils\Arr\ChangeType;
|
||||
use Tez\Utils\Arr\DiffResult;
|
||||
|
||||
final class ArrayDiffTest extends TestCase
|
||||
{
|
||||
public function testIdenticalArraysHaveNoChanges(): void
|
||||
{
|
||||
$result = ArrayDiff::compare(['a' => 1], ['a' => 1]);
|
||||
|
||||
self::assertInstanceOf(DiffResult::class, $result);
|
||||
self::assertFalse($result->hasChanges());
|
||||
self::assertSame([], $result->changes());
|
||||
self::assertSame([], $result->paths());
|
||||
}
|
||||
|
||||
public function testDetectsChangedScalarValue(): void
|
||||
{
|
||||
$result = ArrayDiff::compare(
|
||||
['user' => ['name' => 'Anna', 'city' => 'Berlin']],
|
||||
['user' => ['name' => 'Anna', 'city' => 'Hamburg']],
|
||||
);
|
||||
|
||||
self::assertTrue($result->hasChanges());
|
||||
self::assertCount(1, $result->changes());
|
||||
|
||||
$change = $result->changes()[0];
|
||||
self::assertSame('user.city', $change->path);
|
||||
self::assertSame(ChangeType::Changed, $change->type);
|
||||
self::assertSame('Berlin', $change->old);
|
||||
self::assertSame('Hamburg', $change->new);
|
||||
self::assertSame(['user.city'], $result->paths());
|
||||
}
|
||||
|
||||
public function testDetectsAddedKey(): void
|
||||
{
|
||||
$result = ArrayDiff::compare(
|
||||
['a' => 1],
|
||||
['a' => 1, 'b' => 2],
|
||||
);
|
||||
|
||||
self::assertTrue($result->hasChanges());
|
||||
$change = $result->changes()[0];
|
||||
self::assertSame('b', $change->path);
|
||||
self::assertSame(ChangeType::Added, $change->type);
|
||||
self::assertNull($change->old);
|
||||
self::assertSame(2, $change->new);
|
||||
}
|
||||
|
||||
public function testDetectsRemovedKey(): void
|
||||
{
|
||||
$result = ArrayDiff::compare(
|
||||
['a' => 1, 'b' => 2],
|
||||
['a' => 1],
|
||||
);
|
||||
|
||||
self::assertTrue($result->hasChanges());
|
||||
$change = $result->changes()[0];
|
||||
self::assertSame('b', $change->path);
|
||||
self::assertSame(ChangeType::Removed, $change->type);
|
||||
self::assertSame(2, $change->old);
|
||||
self::assertNull($change->new);
|
||||
}
|
||||
|
||||
public function testDeepNestedPath(): void
|
||||
{
|
||||
$result = ArrayDiff::compare(
|
||||
['a' => ['b' => ['c' => 'old']]],
|
||||
['a' => ['b' => ['c' => 'new']]],
|
||||
);
|
||||
|
||||
self::assertSame(['a.b.c'], $result->paths());
|
||||
self::assertSame(ChangeType::Changed, $result->changes()[0]->type);
|
||||
}
|
||||
|
||||
public function testStrictTypeCheckIntVsString(): void
|
||||
{
|
||||
$result = ArrayDiff::compare(['x' => 1], ['x' => '1']);
|
||||
|
||||
self::assertTrue($result->hasChanges());
|
||||
self::assertSame(ChangeType::Changed, $result->changes()[0]->type);
|
||||
}
|
||||
|
||||
public function testStrictTypeCheckIntVsTrue(): void
|
||||
{
|
||||
$result = ArrayDiff::compare(['x' => 1], ['x' => true]);
|
||||
|
||||
self::assertTrue($result->hasChanges());
|
||||
}
|
||||
|
||||
public function testMultipleDifferencesReported(): void
|
||||
{
|
||||
$result = ArrayDiff::compare(
|
||||
['a' => 1, 'b' => 2, 'c' => 3],
|
||||
['a' => 1, 'b' => 99, 'c' => 3, 'd' => 4],
|
||||
);
|
||||
|
||||
self::assertCount(2, $result->changes());
|
||||
self::assertContains('b', $result->paths());
|
||||
self::assertContains('d', $result->paths());
|
||||
}
|
||||
|
||||
public function testNestedAddedAndRemovedKeys(): void
|
||||
{
|
||||
$result = ArrayDiff::compare(
|
||||
['user' => ['name' => 'Anna', 'age' => 30]],
|
||||
['user' => ['name' => 'Anna', 'email' => 'anna@example.com']],
|
||||
);
|
||||
|
||||
$paths = $result->paths();
|
||||
self::assertContains('user.age', $paths);
|
||||
self::assertContains('user.email', $paths);
|
||||
|
||||
$types = array_map(
|
||||
static fn($c) => $c->type,
|
||||
$result->changes(),
|
||||
);
|
||||
self::assertContains(ChangeType::Removed, $types);
|
||||
self::assertContains(ChangeType::Added, $types);
|
||||
}
|
||||
|
||||
public function testEmptyArraysProduceNoDiff(): void
|
||||
{
|
||||
$result = ArrayDiff::compare([], []);
|
||||
|
||||
self::assertFalse($result->hasChanges());
|
||||
}
|
||||
|
||||
public function testScalarToArrayIsReportedAsChange(): void
|
||||
{
|
||||
$result = ArrayDiff::compare(
|
||||
['x' => 'scalar'],
|
||||
['x' => ['nested' => 'value']],
|
||||
);
|
||||
|
||||
self::assertTrue($result->hasChanges());
|
||||
self::assertSame(ChangeType::Changed, $result->changes()[0]->type);
|
||||
}
|
||||
|
||||
public function testArrayToScalarIsReportedAsChange(): void
|
||||
{
|
||||
$result = ArrayDiff::compare(
|
||||
['x' => ['nested' => 'value']],
|
||||
['x' => 'scalar'],
|
||||
);
|
||||
|
||||
self::assertTrue($result->hasChanges());
|
||||
self::assertSame(ChangeType::Changed, $result->changes()[0]->type);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tez\Utils\Tests\Arr;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Tez\Utils\Arr\CartesianProduct;
|
||||
|
||||
final class CartesianProductTest extends TestCase
|
||||
{
|
||||
public function testEmptySetsYieldsOneEmptyTuple(): void
|
||||
{
|
||||
$result = iterator_to_array(CartesianProduct::create(), false);
|
||||
|
||||
self::assertSame([[]], $result);
|
||||
}
|
||||
|
||||
public function testSingleSet(): void
|
||||
{
|
||||
$result = iterator_to_array(CartesianProduct::create([1, 2, 3]), false);
|
||||
|
||||
self::assertSame([[1], [2], [3]], $result);
|
||||
}
|
||||
|
||||
public function testTwoSets(): void
|
||||
{
|
||||
$result = iterator_to_array(CartesianProduct::create([1, 2], ['a', 'b']), false);
|
||||
|
||||
self::assertSame(
|
||||
[[1, 'a'], [1, 'b'], [2, 'a'], [2, 'b']],
|
||||
$result,
|
||||
);
|
||||
}
|
||||
|
||||
public function testThreeSets(): void
|
||||
{
|
||||
$result = iterator_to_array(
|
||||
CartesianProduct::create(['x', 'y'], [1, 2], ['a', 'b']),
|
||||
false,
|
||||
);
|
||||
|
||||
self::assertCount(8, $result);
|
||||
self::assertSame(['x', 1, 'a'], $result[0]);
|
||||
self::assertSame(['x', 1, 'b'], $result[1]);
|
||||
self::assertSame(['x', 2, 'a'], $result[2]);
|
||||
self::assertSame(['x', 2, 'b'], $result[3]);
|
||||
self::assertSame(['y', 1, 'a'], $result[4]);
|
||||
self::assertSame(['y', 2, 'b'], $result[7]);
|
||||
}
|
||||
|
||||
public function testAssociativeArrayKeysAreStripped(): void
|
||||
{
|
||||
$result = iterator_to_array(
|
||||
CartesianProduct::create(['foo' => 'A', 'bar' => 'B'], ['x' => 1, 'y' => 2]),
|
||||
false,
|
||||
);
|
||||
|
||||
self::assertSame(
|
||||
[['A', 1], ['A', 2], ['B', 1], ['B', 2]],
|
||||
$result,
|
||||
);
|
||||
}
|
||||
|
||||
public function testTotalCountEqualsProductOfSetSizes(): void
|
||||
{
|
||||
$result = iterator_to_array(CartesianProduct::create([1, 2, 3], ['a', 'b'], [true, false]), false);
|
||||
|
||||
self::assertCount(3 * 2 * 2, $result);
|
||||
}
|
||||
|
||||
public function testSingleElementSets(): void
|
||||
{
|
||||
$result = iterator_to_array(CartesianProduct::create(['only'], [42]), false);
|
||||
|
||||
self::assertSame([['only', 42]], $result);
|
||||
}
|
||||
|
||||
public function testGeneratorAsInput(): void
|
||||
{
|
||||
$gen = (static function (): \Generator {
|
||||
yield 10;
|
||||
yield 20;
|
||||
})();
|
||||
|
||||
$result = iterator_to_array(CartesianProduct::create([1, 2], $gen), false);
|
||||
|
||||
self::assertSame([[1, 10], [1, 20], [2, 10], [2, 20]], $result);
|
||||
}
|
||||
|
||||
public function testReturnsGenerator(): void
|
||||
{
|
||||
$generator = CartesianProduct::create([1], [2]);
|
||||
|
||||
self::assertInstanceOf(\Generator::class, $generator);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tez\Utils\Tests\Arr;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Tez\Utils\Arr\DeepMerge;
|
||||
|
||||
final class DeepMergeTest extends TestCase
|
||||
{
|
||||
public function testMergesTopLevelScalars(): void
|
||||
{
|
||||
$result = DeepMerge::merge(
|
||||
['a' => 1, 'b' => 2],
|
||||
['b' => 99, 'c' => 3],
|
||||
);
|
||||
|
||||
self::assertSame(['a' => 1, 'b' => 99, 'c' => 3], $result);
|
||||
}
|
||||
|
||||
public function testMergesNestedArraysRecursively(): void
|
||||
{
|
||||
$result = DeepMerge::merge(
|
||||
['db' => ['host' => 'localhost', 'port' => 3306]],
|
||||
['db' => ['host' => 'db.prod']],
|
||||
);
|
||||
|
||||
self::assertSame(['db' => ['host' => 'db.prod', 'port' => 3306]], $result);
|
||||
}
|
||||
|
||||
public function testBaseOnlyKeysAreKept(): void
|
||||
{
|
||||
$result = DeepMerge::merge(
|
||||
['a' => 1, 'b' => 2],
|
||||
['b' => 99],
|
||||
);
|
||||
|
||||
self::assertSame(1, $result['a']);
|
||||
}
|
||||
|
||||
public function testOverrideOnlyKeysAreAdded(): void
|
||||
{
|
||||
$result = DeepMerge::merge(['a' => 1], ['b' => 2]);
|
||||
|
||||
self::assertSame(['a' => 1, 'b' => 2], $result);
|
||||
}
|
||||
|
||||
public function testNullOverwrites(): void
|
||||
{
|
||||
$result = DeepMerge::merge(['a' => 'value'], ['a' => null]);
|
||||
|
||||
self::assertNull($result['a']);
|
||||
}
|
||||
|
||||
public function testIntegerKeysAreAppended(): void
|
||||
{
|
||||
$result = DeepMerge::merge([1, 2, 3], [4, 5]);
|
||||
|
||||
self::assertSame([1, 2, 3, 4, 5], $result);
|
||||
}
|
||||
|
||||
public function testMultipleOverridesAppliedLeftToRight(): void
|
||||
{
|
||||
$result = DeepMerge::merge(
|
||||
['a' => 1],
|
||||
['a' => 2],
|
||||
['a' => 3],
|
||||
);
|
||||
|
||||
self::assertSame(3, $result['a']);
|
||||
}
|
||||
|
||||
public function testDeepNestedMerge(): void
|
||||
{
|
||||
$defaults = [
|
||||
'cache' => ['ttl' => 3600, 'driver' => 'file'],
|
||||
'debug' => false,
|
||||
];
|
||||
$env = [
|
||||
'cache' => ['driver' => 'redis'],
|
||||
'debug' => true,
|
||||
];
|
||||
|
||||
$result = DeepMerge::merge($defaults, $env);
|
||||
|
||||
/** @var array<string, mixed> $cache */
|
||||
$cache = $result['cache'];
|
||||
self::assertSame(3600, $cache['ttl']);
|
||||
self::assertSame('redis', $cache['driver']);
|
||||
self::assertTrue($result['debug']);
|
||||
}
|
||||
|
||||
public function testOverridingArrayWithScalarWins(): void
|
||||
{
|
||||
$result = DeepMerge::merge(
|
||||
['key' => ['nested' => 1]],
|
||||
['key' => 'scalar'],
|
||||
);
|
||||
|
||||
self::assertSame('scalar', $result['key']);
|
||||
}
|
||||
|
||||
public function testEmptyOverrideReturnsBase(): void
|
||||
{
|
||||
$base = ['a' => 1, 'b' => 2];
|
||||
self::assertSame($base, DeepMerge::merge($base));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tez\Utils\Tests\Arr;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Tez\Utils\Arr\Find;
|
||||
|
||||
final class FindTest extends TestCase
|
||||
{
|
||||
public function testFirstReturnsFirstMatch(): void
|
||||
{
|
||||
$items = [1, 2, 3, 4, 5];
|
||||
|
||||
$result = Find::first($items, fn($v) => $v > 2);
|
||||
|
||||
self::assertSame(3, $result);
|
||||
}
|
||||
|
||||
public function testFirstReturnsNullWhenNoMatch(): void
|
||||
{
|
||||
/** @var list<int> $items */
|
||||
$items = [1, 2, 3];
|
||||
$result = Find::first($items, fn($v) => $v > 10);
|
||||
|
||||
self::assertNull($result);
|
||||
}
|
||||
|
||||
public function testFirstReturnsNullOnEmptyArray(): void
|
||||
{
|
||||
$result = Find::first([], fn($v) => true);
|
||||
|
||||
self::assertNull($result);
|
||||
}
|
||||
|
||||
public function testFirstStopsAtFirstMatch(): void
|
||||
{
|
||||
$calls = 0;
|
||||
$items = [1, 2, 3, 4, 5];
|
||||
|
||||
Find::first($items, function ($v) use (&$calls): bool {
|
||||
$calls++;
|
||||
|
||||
return $v === 2;
|
||||
});
|
||||
|
||||
self::assertSame(2, $calls);
|
||||
}
|
||||
|
||||
public function testFirstWithAssociativeArray(): void
|
||||
{
|
||||
$items = ['a' => 1, 'b' => 2, 'c' => 3];
|
||||
|
||||
$result = Find::first($items, fn($v) => $v === 2);
|
||||
|
||||
self::assertSame(2, $result);
|
||||
}
|
||||
|
||||
public function testFirstWithObjects(): void
|
||||
{
|
||||
$a = new \stdClass();
|
||||
$a->id = 1;
|
||||
$b = new \stdClass();
|
||||
$b->id = 2;
|
||||
|
||||
$result = Find::first([$a, $b], fn($o) => $o->id === 2);
|
||||
|
||||
self::assertSame($b, $result);
|
||||
}
|
||||
|
||||
public function testLastReturnsLastMatch(): void
|
||||
{
|
||||
$items = [1, 2, 3, 4, 5];
|
||||
|
||||
$result = Find::last($items, fn($v) => $v < 4);
|
||||
|
||||
self::assertSame(3, $result);
|
||||
}
|
||||
|
||||
public function testLastReturnsNullWhenNoMatch(): void
|
||||
{
|
||||
/** @var list<int> $items */
|
||||
$items = [1, 2, 3];
|
||||
$result = Find::last($items, fn($v) => $v > 10);
|
||||
|
||||
self::assertNull($result);
|
||||
}
|
||||
|
||||
public function testLastReturnsNullOnEmptyArray(): void
|
||||
{
|
||||
$result = Find::last([], fn($v) => true);
|
||||
|
||||
self::assertNull($result);
|
||||
}
|
||||
|
||||
public function testLastWithObjects(): void
|
||||
{
|
||||
$a = new \stdClass();
|
||||
$a->active = true;
|
||||
$b = new \stdClass();
|
||||
$b->active = false;
|
||||
$c = new \stdClass();
|
||||
$c->active = true;
|
||||
|
||||
$result = Find::last([$a, $b, $c], fn($o) => $o->active);
|
||||
|
||||
self::assertSame($c, $result);
|
||||
}
|
||||
|
||||
public function testFirstAndLastDifferWhenMultipleMatches(): void
|
||||
{
|
||||
$items = [10, 20, 30, 40, 50];
|
||||
|
||||
$first = Find::first($items, fn($v) => $v % 20 === 0);
|
||||
$last = Find::last($items, fn($v) => $v % 20 === 0);
|
||||
|
||||
self::assertSame(20, $first);
|
||||
self::assertSame(40, $last);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tez\Utils\Tests\Arr;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Tez\Utils\Arr\Flatten;
|
||||
|
||||
final class FlattenTest extends TestCase
|
||||
{
|
||||
public function testFlattensFullyByDefault(): void
|
||||
{
|
||||
$result = Flatten::flatten([1, [2, 3], [4, [5, 6]]]);
|
||||
|
||||
self::assertSame([1, 2, 3, 4, 5, 6], $result);
|
||||
}
|
||||
|
||||
public function testDepthOne(): void
|
||||
{
|
||||
$result = Flatten::flatten([1, [2, 3], [4, [5, 6]]], depth: 1);
|
||||
|
||||
self::assertSame([1, 2, 3, 4, [5, 6]], $result);
|
||||
}
|
||||
|
||||
public function testDepthTwo(): void
|
||||
{
|
||||
$result = Flatten::flatten([1, [2, [3, [4]]]], depth: 2);
|
||||
|
||||
self::assertSame([1, 2, 3, [4]], $result);
|
||||
}
|
||||
|
||||
public function testAlreadyFlatArrayIsUnchanged(): void
|
||||
{
|
||||
$result = Flatten::flatten([1, 2, 3]);
|
||||
|
||||
self::assertSame([1, 2, 3], $result);
|
||||
}
|
||||
|
||||
public function testEmptyArrayReturnsEmpty(): void
|
||||
{
|
||||
self::assertSame([], Flatten::flatten([]));
|
||||
}
|
||||
|
||||
public function testAssociativeKeysAreDiscarded(): void
|
||||
{
|
||||
$result = Flatten::flatten([['a' => 1, 'b' => 2], ['c' => 3]]);
|
||||
|
||||
self::assertSame([1, 2, 3], $result);
|
||||
}
|
||||
|
||||
public function testDepthZeroReturnsOriginalValues(): void
|
||||
{
|
||||
$result = Flatten::flatten([[1, 2], [3, 4]], depth: 0);
|
||||
|
||||
self::assertSame([[1, 2], [3, 4]], $result);
|
||||
}
|
||||
|
||||
public function testMixedNestingDepths(): void
|
||||
{
|
||||
$result = Flatten::flatten([1, [2, [3]], 4, [[5]]]);
|
||||
|
||||
self::assertSame([1, 2, 3, 4, 5], $result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tez\Utils\Tests\Arr;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Tez\Utils\Arr\GroupBy;
|
||||
|
||||
final class GroupByTest extends TestCase
|
||||
{
|
||||
public function testGroupsByStringKey(): void
|
||||
{
|
||||
$users = [
|
||||
['name' => 'Alice', 'role' => 'admin'],
|
||||
['name' => 'Bob', 'role' => 'user'],
|
||||
['name' => 'Carol', 'role' => 'admin'],
|
||||
];
|
||||
|
||||
$result = GroupBy::group($users, fn($u) => $u['role']);
|
||||
|
||||
self::assertSame([
|
||||
'admin' => [
|
||||
['name' => 'Alice', 'role' => 'admin'],
|
||||
['name' => 'Carol', 'role' => 'admin'],
|
||||
],
|
||||
'user' => [
|
||||
['name' => 'Bob', 'role' => 'user'],
|
||||
],
|
||||
], $result);
|
||||
}
|
||||
|
||||
public function testGroupsByIntKey(): void
|
||||
{
|
||||
$result = GroupBy::group(range(1, 6), fn($n) => $n % 2);
|
||||
|
||||
self::assertSame([
|
||||
1 => [1, 3, 5],
|
||||
0 => [2, 4, 6],
|
||||
], $result);
|
||||
}
|
||||
|
||||
public function testGroupsPreserveDefinitionOrder(): void
|
||||
{
|
||||
$items = ['b', 'a', 'b', 'a', 'c'];
|
||||
$result = GroupBy::group($items, fn($v) => $v);
|
||||
|
||||
self::assertSame(['b', 'b'], $result['b']);
|
||||
self::assertSame(['a', 'a'], $result['a']);
|
||||
self::assertSame(['c'], $result['c']);
|
||||
}
|
||||
|
||||
public function testEmptyArrayReturnsEmpty(): void
|
||||
{
|
||||
self::assertSame([], GroupBy::group([], fn($v) => $v));
|
||||
}
|
||||
|
||||
public function testSingleGroupWhenAllItemsShareKey(): void
|
||||
{
|
||||
$result = GroupBy::group([1, 2, 3], fn() => 'same');
|
||||
|
||||
self::assertSame(['same' => [1, 2, 3]], $result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tez\Utils\Tests\Arr;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Tez\Utils\Arr\KeyBy;
|
||||
|
||||
final class KeyByTest extends TestCase
|
||||
{
|
||||
public function testIndexByStringKey(): void
|
||||
{
|
||||
$users = [
|
||||
['id' => 1, 'name' => 'Alice'],
|
||||
['id' => 2, 'name' => 'Bob'],
|
||||
];
|
||||
|
||||
$result = KeyBy::index($users, fn($u) => $u['name']);
|
||||
|
||||
self::assertSame([
|
||||
'Alice' => ['id' => 1, 'name' => 'Alice'],
|
||||
'Bob' => ['id' => 2, 'name' => 'Bob'],
|
||||
], $result);
|
||||
}
|
||||
|
||||
public function testIndexByIntKey(): void
|
||||
{
|
||||
$users = [
|
||||
['id' => 1, 'name' => 'Alice'],
|
||||
['id' => 2, 'name' => 'Bob'],
|
||||
];
|
||||
|
||||
$result = KeyBy::index($users, fn($u) => $u['id']);
|
||||
|
||||
self::assertSame(1, array_key_first($result));
|
||||
self::assertSame('Alice', $result[1]['name']);
|
||||
}
|
||||
|
||||
public function testLastItemWinsOnDuplicateKey(): void
|
||||
{
|
||||
$items = [
|
||||
['key' => 'x', 'val' => 1],
|
||||
['key' => 'x', 'val' => 2],
|
||||
];
|
||||
|
||||
$result = KeyBy::index($items, fn($i) => $i['key']);
|
||||
|
||||
self::assertSame(2, $result['x']['val']);
|
||||
}
|
||||
|
||||
public function testEmptyInputReturnsEmpty(): void
|
||||
{
|
||||
self::assertSame([], KeyBy::index([], fn($v) => $v));
|
||||
}
|
||||
|
||||
public function testFullItemIsPreserved(): void
|
||||
{
|
||||
$items = [['id' => 1, 'name' => 'Alice', 'score' => 95]];
|
||||
|
||||
$result = KeyBy::index($items, fn($i) => $i['id']);
|
||||
|
||||
self::assertSame(['id' => 1, 'name' => 'Alice', 'score' => 95], $result[1]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tez\Utils\Tests\Arr;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Tez\Utils\Arr\MapWithKeys;
|
||||
|
||||
final class MapWithKeysTest extends TestCase
|
||||
{
|
||||
public function testTransformsKeysAndValues(): void
|
||||
{
|
||||
/** @var list<array{id: int, name: string}> $users */
|
||||
$users = [
|
||||
['id' => 1, 'name' => 'Alice'],
|
||||
['id' => 2, 'name' => 'Bob'],
|
||||
];
|
||||
|
||||
$result = MapWithKeys::map($users, function (mixed $u) {
|
||||
/** @var array{id: int, name: string} $u */
|
||||
return [$u['id'] => $u['name']];
|
||||
});
|
||||
|
||||
self::assertSame([1 => 'Alice', 2 => 'Bob'], $result);
|
||||
}
|
||||
|
||||
public function testSwapsKeyAndValue(): void
|
||||
{
|
||||
$result = MapWithKeys::map(['a' => 1, 'b' => 2], function (mixed $v, int|string $k) {
|
||||
/** @var int $v */
|
||||
return [$v => $k];
|
||||
});
|
||||
|
||||
self::assertSame([1 => 'a', 2 => 'b'], $result);
|
||||
}
|
||||
|
||||
public function testLastWinsOnDuplicateKey(): void
|
||||
{
|
||||
$result = MapWithKeys::map([1, 2, 3], fn($v) => ['key' => $v]);
|
||||
|
||||
self::assertSame(['key' => 3], $result);
|
||||
}
|
||||
|
||||
public function testEmptyInputReturnsEmpty(): void
|
||||
{
|
||||
self::assertSame([], MapWithKeys::map([], fn($v) => ['x' => $v]));
|
||||
}
|
||||
|
||||
public function testReceivesOriginalKey(): void
|
||||
{
|
||||
$received = [];
|
||||
MapWithKeys::map(['x' => 10, 'y' => 20], function ($v, $k) use (&$received) {
|
||||
$received[] = $k;
|
||||
|
||||
return [$k => $v];
|
||||
});
|
||||
|
||||
self::assertSame(['x', 'y'], $received);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tez\Utils\Tests\Arr;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Tez\Utils\Arr\Page;
|
||||
use Tez\Utils\Arr\Paginator;
|
||||
|
||||
final class PaginatorTest extends TestCase
|
||||
{
|
||||
/** @return list<int> */
|
||||
private function range100(): array
|
||||
{
|
||||
return range(1, 100);
|
||||
}
|
||||
|
||||
public function testFirstPageReturnsCorrectSlice(): void
|
||||
{
|
||||
$page = Paginator::paginate($this->range100(), page: 1, perPage: 10);
|
||||
|
||||
self::assertInstanceOf(Page::class, $page);
|
||||
self::assertSame(range(1, 10), $page->items());
|
||||
self::assertSame(100, $page->total());
|
||||
self::assertSame(1, $page->currentPage());
|
||||
self::assertSame(10, $page->lastPage());
|
||||
}
|
||||
|
||||
public function testSecondPageReturnsCorrectSlice(): void
|
||||
{
|
||||
$page = Paginator::paginate($this->range100(), page: 2, perPage: 10);
|
||||
|
||||
self::assertSame(range(11, 20), $page->items());
|
||||
self::assertSame(2, $page->currentPage());
|
||||
}
|
||||
|
||||
public function testLastPageHasFewerItems(): void
|
||||
{
|
||||
$items = range(1, 25);
|
||||
$page = Paginator::paginate($items, page: 3, perPage: 10);
|
||||
|
||||
self::assertSame([21, 22, 23, 24, 25], $page->items());
|
||||
self::assertSame(3, $page->lastPage());
|
||||
self::assertFalse($page->hasNext());
|
||||
self::assertTrue($page->hasPrev());
|
||||
}
|
||||
|
||||
public function testHasNextAndHasPrev(): void
|
||||
{
|
||||
$items = range(1, 30);
|
||||
|
||||
$first = Paginator::paginate($items, page: 1, perPage: 10);
|
||||
self::assertTrue($first->hasNext());
|
||||
self::assertFalse($first->hasPrev());
|
||||
|
||||
$middle = Paginator::paginate($items, page: 2, perPage: 10);
|
||||
self::assertTrue($middle->hasNext());
|
||||
self::assertTrue($middle->hasPrev());
|
||||
|
||||
$last = Paginator::paginate($items, page: 3, perPage: 10);
|
||||
self::assertFalse($last->hasNext());
|
||||
self::assertTrue($last->hasPrev());
|
||||
}
|
||||
|
||||
public function testEmptyArrayReturnsEmptyPage(): void
|
||||
{
|
||||
$page = Paginator::paginate([], page: 1, perPage: 10);
|
||||
|
||||
self::assertSame([], $page->items());
|
||||
self::assertSame(0, $page->total());
|
||||
self::assertSame(1, $page->lastPage());
|
||||
self::assertFalse($page->hasNext());
|
||||
self::assertFalse($page->hasPrev());
|
||||
}
|
||||
|
||||
public function testPageBeyondLastPageReturnsEmptyItems(): void
|
||||
{
|
||||
$page = Paginator::paginate(range(1, 5), page: 10, perPage: 10);
|
||||
|
||||
self::assertSame([], $page->items());
|
||||
}
|
||||
|
||||
public function testPageZeroThrowsException(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
|
||||
Paginator::paginate([], page: 0, perPage: 10);
|
||||
}
|
||||
|
||||
public function testNegativePageThrowsException(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
|
||||
Paginator::paginate([], page: -1, perPage: 10);
|
||||
}
|
||||
|
||||
public function testPerPageZeroThrowsException(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
|
||||
Paginator::paginate([], page: 1, perPage: 0);
|
||||
}
|
||||
|
||||
public function testPerPageNegativeThrowsException(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
|
||||
Paginator::paginate([], page: 1, perPage: -5);
|
||||
}
|
||||
|
||||
public function testItemsAreReIndexed(): void
|
||||
{
|
||||
$items = ['a' => 'x', 'b' => 'y', 'c' => 'z'];
|
||||
$page = Paginator::paginate(array_values($items), page: 1, perPage: 2);
|
||||
|
||||
self::assertSame(['x', 'y'], $page->items());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tez\Utils\Tests\Arr;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Tez\Utils\Arr\Partition;
|
||||
|
||||
final class PartitionTest extends TestCase
|
||||
{
|
||||
public function testSplitsIntoPassAndFail(): void
|
||||
{
|
||||
/** @var list<int> $numbers */
|
||||
$numbers = [1, 2, 3, 4, 5, 6];
|
||||
|
||||
[$even, $odd] = Partition::by($numbers, fn($n) => $n % 2 === 0);
|
||||
|
||||
self::assertSame([2, 4, 6], $even);
|
||||
self::assertSame([1, 3, 5], $odd);
|
||||
}
|
||||
|
||||
public function testAllPassReturnsEmptyFail(): void
|
||||
{
|
||||
[$pass, $fail] = Partition::by([1, 2, 3], fn() => true);
|
||||
|
||||
self::assertSame([1, 2, 3], $pass);
|
||||
self::assertSame([], $fail);
|
||||
}
|
||||
|
||||
public function testAllFailReturnsEmptyPass(): void
|
||||
{
|
||||
[$pass, $fail] = Partition::by([1, 2, 3], fn() => false);
|
||||
|
||||
self::assertSame([], $pass);
|
||||
self::assertSame([1, 2, 3], $fail);
|
||||
}
|
||||
|
||||
public function testEmptyArrayReturnsTwoEmptyLists(): void
|
||||
{
|
||||
[$pass, $fail] = Partition::by([], fn() => true);
|
||||
|
||||
self::assertSame([], $pass);
|
||||
self::assertSame([], $fail);
|
||||
}
|
||||
|
||||
public function testWorksWithAssocArrayItems(): void
|
||||
{
|
||||
$users = [
|
||||
['name' => 'Alice', 'active' => true],
|
||||
['name' => 'Bob', 'active' => false],
|
||||
['name' => 'Carol', 'active' => true],
|
||||
];
|
||||
|
||||
[$active, $inactive] = Partition::by($users, fn($u) => $u['active']);
|
||||
|
||||
self::assertCount(2, $active);
|
||||
self::assertCount(1, $inactive);
|
||||
self::assertSame('Alice', $active[0]['name']);
|
||||
self::assertSame('Carol', $active[1]['name']);
|
||||
}
|
||||
|
||||
public function testResultIsReindexed(): void
|
||||
{
|
||||
[$pass, $fail] = Partition::by([10, 20, 30, 40], fn($n) => $n > 15);
|
||||
|
||||
self::assertSame(0, array_key_first($pass));
|
||||
self::assertSame(0, array_key_first($fail));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tez\Utils\Tests\Arr;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Tez\Utils\Arr\Pluck;
|
||||
|
||||
final class PluckTest extends TestCase
|
||||
{
|
||||
public function testPlucksTopLevelKey(): void
|
||||
{
|
||||
$items = [
|
||||
['name' => 'Alice', 'age' => 28],
|
||||
['name' => 'Bob', 'age' => 34],
|
||||
];
|
||||
|
||||
self::assertSame(['Alice', 'Bob'], Pluck::values($items, 'name'));
|
||||
}
|
||||
|
||||
public function testPlucksDotNotationKey(): void
|
||||
{
|
||||
$items = [
|
||||
['id' => 1, 'profile' => ['name' => 'Alice']],
|
||||
['id' => 2, 'profile' => ['name' => 'Bob']],
|
||||
];
|
||||
|
||||
self::assertSame(['Alice', 'Bob'], Pluck::values($items, 'profile.name'));
|
||||
}
|
||||
|
||||
public function testSkipsMissingKeys(): void
|
||||
{
|
||||
$items = [
|
||||
['name' => 'Alice'],
|
||||
['age' => 30],
|
||||
['name' => 'Carol'],
|
||||
];
|
||||
|
||||
self::assertSame(['Alice', 'Carol'], Pluck::values($items, 'name'));
|
||||
}
|
||||
|
||||
public function testKeyedByReturnsAssocArray(): void
|
||||
{
|
||||
$items = [
|
||||
['id' => 1, 'name' => 'Alice'],
|
||||
['id' => 2, 'name' => 'Bob'],
|
||||
];
|
||||
|
||||
self::assertSame([1 => 'Alice', 2 => 'Bob'], Pluck::keyedBy($items, 'name', 'id'));
|
||||
}
|
||||
|
||||
public function testKeyedByWithDotNotation(): void
|
||||
{
|
||||
$items = [
|
||||
['id' => 1, 'profile' => ['name' => 'Alice']],
|
||||
['id' => 2, 'profile' => ['name' => 'Bob']],
|
||||
];
|
||||
|
||||
self::assertSame([1 => 'Alice', 2 => 'Bob'], Pluck::keyedBy($items, 'profile.name', 'id'));
|
||||
}
|
||||
|
||||
public function testEmptyInputReturnsEmpty(): void
|
||||
{
|
||||
self::assertSame([], Pluck::values([], 'name'));
|
||||
self::assertSame([], Pluck::keyedBy([], 'name', 'id'));
|
||||
}
|
||||
|
||||
public function testPlucksNullValues(): void
|
||||
{
|
||||
$items = [
|
||||
['name' => null],
|
||||
['name' => 'Bob'],
|
||||
];
|
||||
|
||||
self::assertSame([null, 'Bob'], Pluck::values($items, 'name'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tez\Utils\Tests\Arr;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Tez\Utils\Arr\SafeGet;
|
||||
|
||||
final class SafeGetTest extends TestCase
|
||||
{
|
||||
/** @var array<mixed> */
|
||||
private array $data;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->data = [
|
||||
'user' => [
|
||||
'name' => 'Alice',
|
||||
'age' => 30,
|
||||
'score' => 9.5,
|
||||
'active' => true,
|
||||
'tags' => ['admin', 'user'],
|
||||
],
|
||||
'chart' => [
|
||||
'result' => [
|
||||
[
|
||||
'meta' => ['currency' => 'EUR', 'price' => 123.45],
|
||||
'data' => [10, 20, 30],
|
||||
],
|
||||
],
|
||||
],
|
||||
'empty_string' => '',
|
||||
'zero' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
// ── string ────────────────────────────────────────────────────────────────
|
||||
|
||||
public function testStringShallowKey(): void
|
||||
{
|
||||
self::assertSame('Alice', SafeGet::string($this->data, 'user.name'));
|
||||
}
|
||||
|
||||
public function testStringDeeplyNested(): void
|
||||
{
|
||||
self::assertSame('EUR', SafeGet::string($this->data, 'chart.result.0.meta.currency'));
|
||||
}
|
||||
|
||||
public function testStringMissingPathReturnsDefault(): void
|
||||
{
|
||||
self::assertSame('fallback', SafeGet::string($this->data, 'user.missing', 'fallback'));
|
||||
}
|
||||
|
||||
public function testStringMissingPathReturnsNullByDefault(): void
|
||||
{
|
||||
self::assertNull(SafeGet::string($this->data, 'does.not.exist'));
|
||||
}
|
||||
|
||||
public function testStringWrongTypeReturnsDefault(): void
|
||||
{
|
||||
// age is int, not string
|
||||
self::assertNull(SafeGet::string($this->data, 'user.age'));
|
||||
}
|
||||
|
||||
// ── int ───────────────────────────────────────────────────────────────────
|
||||
|
||||
public function testIntShallowKey(): void
|
||||
{
|
||||
self::assertSame(30, SafeGet::int($this->data, 'user.age'));
|
||||
}
|
||||
|
||||
public function testIntFromListIndex(): void
|
||||
{
|
||||
self::assertSame(20, SafeGet::int($this->data, 'chart.result.0.data.1'));
|
||||
}
|
||||
|
||||
public function testIntMissingReturnsDefault(): void
|
||||
{
|
||||
self::assertSame(42, SafeGet::int($this->data, 'user.missing', 42));
|
||||
}
|
||||
|
||||
public function testIntWrongTypeReturnsDefault(): void
|
||||
{
|
||||
// name is string, not int
|
||||
self::assertNull(SafeGet::int($this->data, 'user.name'));
|
||||
}
|
||||
|
||||
// ── float ─────────────────────────────────────────────────────────────────
|
||||
|
||||
public function testFloatDirectValue(): void
|
||||
{
|
||||
self::assertSame(9.5, SafeGet::float($this->data, 'user.score'));
|
||||
}
|
||||
|
||||
public function testFloatWidensFromInt(): void
|
||||
{
|
||||
// age is int 30 → widened to 30.0
|
||||
self::assertSame(30.0, SafeGet::float($this->data, 'user.age'));
|
||||
}
|
||||
|
||||
public function testFloatNestedValue(): void
|
||||
{
|
||||
self::assertSame(123.45, SafeGet::float($this->data, 'chart.result.0.meta.price'));
|
||||
}
|
||||
|
||||
public function testFloatMissingReturnsDefault(): void
|
||||
{
|
||||
self::assertSame(1.5, SafeGet::float($this->data, 'missing', 1.5));
|
||||
}
|
||||
|
||||
// ── bool ──────────────────────────────────────────────────────────────────
|
||||
|
||||
public function testBoolTrue(): void
|
||||
{
|
||||
self::assertTrue(SafeGet::bool($this->data, 'user.active'));
|
||||
}
|
||||
|
||||
public function testBoolMissingReturnsDefault(): void
|
||||
{
|
||||
self::assertFalse(SafeGet::bool($this->data, 'user.missing', false));
|
||||
}
|
||||
|
||||
public function testBoolWrongTypeReturnsDefault(): void
|
||||
{
|
||||
// integer 1 is not a bool
|
||||
self::assertNull(SafeGet::bool($this->data, 'user.age'));
|
||||
}
|
||||
|
||||
// ── array ─────────────────────────────────────────────────────────────────
|
||||
|
||||
public function testArrayShallowKey(): void
|
||||
{
|
||||
self::assertSame(['admin', 'user'], SafeGet::array($this->data, 'user.tags'));
|
||||
}
|
||||
|
||||
public function testArrayNestedObject(): void
|
||||
{
|
||||
$result = SafeGet::array($this->data, 'chart.result.0');
|
||||
self::assertIsArray($result);
|
||||
self::assertArrayHasKey('meta', $result);
|
||||
}
|
||||
|
||||
public function testArrayMissingReturnsDefault(): void
|
||||
{
|
||||
self::assertSame(['x'], SafeGet::array($this->data, 'missing', ['x']));
|
||||
}
|
||||
|
||||
public function testArrayMissingReturnsNullByDefault(): void
|
||||
{
|
||||
self::assertNull(SafeGet::array($this->data, 'user.name'));
|
||||
}
|
||||
|
||||
// ── edge cases ────────────────────────────────────────────────────────────
|
||||
|
||||
public function testMidPathNotArrayReturnsDefault(): void
|
||||
{
|
||||
// 'user.name' is a string; trying to go deeper must return null
|
||||
self::assertNull(SafeGet::string($this->data, 'user.name.nested'));
|
||||
}
|
||||
|
||||
public function testNumericStringKeyAddressesListIndex(): void
|
||||
{
|
||||
self::assertSame(10, SafeGet::int($this->data, 'chart.result.0.data.0'));
|
||||
self::assertSame(30, SafeGet::int($this->data, 'chart.result.0.data.2'));
|
||||
}
|
||||
|
||||
public function testEmptyPathSegmentsAreHandled(): void
|
||||
{
|
||||
$flat = ['key' => 'value'];
|
||||
self::assertSame('value', SafeGet::string($flat, 'key'));
|
||||
}
|
||||
|
||||
public function testRootLevelArrayAccess(): void
|
||||
{
|
||||
$data = ['name' => 'Bob'];
|
||||
self::assertSame('Bob', SafeGet::string($data, 'name'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tez\Utils\Tests\Arr;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Tez\Utils\Arr\Sliding;
|
||||
|
||||
final class SlidingTest extends TestCase
|
||||
{
|
||||
public function testDefaultStepOfOne(): void
|
||||
{
|
||||
$result = Sliding::window([1, 2, 3, 4, 5], size: 3);
|
||||
|
||||
self::assertSame([[1, 2, 3], [2, 3, 4], [3, 4, 5]], $result);
|
||||
}
|
||||
|
||||
public function testCustomStep(): void
|
||||
{
|
||||
$result = Sliding::window([1, 2, 3, 4, 5], size: 3, step: 2);
|
||||
|
||||
self::assertSame([[1, 2, 3], [3, 4, 5]], $result);
|
||||
}
|
||||
|
||||
public function testStepEqualsSizeProducesNonOverlappingChunks(): void
|
||||
{
|
||||
$result = Sliding::window([1, 2, 3, 4, 5, 6], size: 2, step: 2);
|
||||
|
||||
self::assertSame([[1, 2], [3, 4], [5, 6]], $result);
|
||||
}
|
||||
|
||||
public function testArraySmallerThanWindowReturnsEmpty(): void
|
||||
{
|
||||
self::assertSame([], Sliding::window([1, 2], size: 5));
|
||||
}
|
||||
|
||||
public function testExactFitProducesOneWindow(): void
|
||||
{
|
||||
$result = Sliding::window([1, 2, 3], size: 3);
|
||||
|
||||
self::assertSame([[1, 2, 3]], $result);
|
||||
}
|
||||
|
||||
public function testSizeOneReturnsEachElementWrapped(): void
|
||||
{
|
||||
$result = Sliding::window([1, 2, 3], size: 1);
|
||||
|
||||
self::assertSame([[1], [2], [3]], $result);
|
||||
}
|
||||
|
||||
public function testEmptyArrayReturnsEmpty(): void
|
||||
{
|
||||
self::assertSame([], Sliding::window([], size: 3));
|
||||
}
|
||||
|
||||
public function testInvalidSizeThrows(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
Sliding::window([1, 2, 3], size: 0);
|
||||
}
|
||||
|
||||
public function testInvalidStepThrows(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
Sliding::window([1, 2, 3], size: 2, step: 0);
|
||||
}
|
||||
|
||||
public function testAssociativeKeysAreStripped(): void
|
||||
{
|
||||
$result = Sliding::window(['a' => 1, 'b' => 2, 'c' => 3], size: 2);
|
||||
|
||||
self::assertSame([[1, 2], [2, 3]], $result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tez\Utils\Tests\Arr;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Tez\Utils\Arr\SortBy;
|
||||
|
||||
final class SortByTest extends TestCase
|
||||
{
|
||||
public function testSortsByIntKeyAscending(): void
|
||||
{
|
||||
$users = [
|
||||
['name' => 'Carol', 'age' => 32],
|
||||
['name' => 'Alice', 'age' => 28],
|
||||
['name' => 'Bob', 'age' => 45],
|
||||
];
|
||||
|
||||
$result = SortBy::sort($users, fn($u) => $u['age']);
|
||||
|
||||
self::assertSame('Alice', $result[0]['name']);
|
||||
self::assertSame('Carol', $result[1]['name']);
|
||||
self::assertSame('Bob', $result[2]['name']);
|
||||
}
|
||||
|
||||
public function testSortsByIntKeyDescending(): void
|
||||
{
|
||||
$users = [
|
||||
['name' => 'Carol', 'age' => 32],
|
||||
['name' => 'Alice', 'age' => 28],
|
||||
['name' => 'Bob', 'age' => 45],
|
||||
];
|
||||
|
||||
$result = SortBy::sort($users, fn($u) => $u['age'], descending: true);
|
||||
|
||||
self::assertSame('Bob', $result[0]['name']);
|
||||
self::assertSame('Carol', $result[1]['name']);
|
||||
self::assertSame('Alice', $result[2]['name']);
|
||||
}
|
||||
|
||||
public function testSortsByStringKey(): void
|
||||
{
|
||||
$items = ['banana', 'apple', 'cherry'];
|
||||
|
||||
$result = SortBy::sort($items, fn($v) => $v);
|
||||
|
||||
self::assertSame(['apple', 'banana', 'cherry'], $result);
|
||||
}
|
||||
|
||||
public function testDoesNotMutateOriginal(): void
|
||||
{
|
||||
$original = [3, 1, 2];
|
||||
SortBy::sort($original, fn($v) => $v);
|
||||
|
||||
self::assertSame([3, 1, 2], $original);
|
||||
}
|
||||
|
||||
public function testResultIsReindexed(): void
|
||||
{
|
||||
$result = SortBy::sort([3, 1, 2], fn($v) => $v);
|
||||
|
||||
self::assertSame(0, array_key_first($result));
|
||||
self::assertSame(1, $result[0]);
|
||||
}
|
||||
|
||||
public function testEmptyArrayReturnsEmpty(): void
|
||||
{
|
||||
self::assertSame([], SortBy::sort([], fn($v) => $v));
|
||||
}
|
||||
|
||||
public function testStableSortPreservesEqualOrder(): void
|
||||
{
|
||||
$items = [
|
||||
['name' => 'A', 'age' => 30],
|
||||
['name' => 'B', 'age' => 30],
|
||||
];
|
||||
|
||||
$result = SortBy::sort($items, fn($u) => $u['age']);
|
||||
|
||||
// Equal keys: original order must be preserved (PHP's usort is stable since 8.0)
|
||||
self::assertSame('A', $result[0]['name']);
|
||||
self::assertSame('B', $result[1]['name']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tez\Utils\Tests\Arr;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Tez\Utils\Arr\Transpose;
|
||||
|
||||
final class TransposeTest extends TestCase
|
||||
{
|
||||
public function testTransposesMatrix(): void
|
||||
{
|
||||
$result = Transpose::matrix([
|
||||
[1, 2, 3],
|
||||
[4, 5, 6],
|
||||
]);
|
||||
|
||||
self::assertSame([[1, 4], [2, 5], [3, 6]], $result);
|
||||
}
|
||||
|
||||
public function testTransposesSquareMatrix(): void
|
||||
{
|
||||
$result = Transpose::matrix([
|
||||
[1, 2],
|
||||
[3, 4],
|
||||
]);
|
||||
|
||||
self::assertSame([[1, 3], [2, 4]], $result);
|
||||
}
|
||||
|
||||
public function testTransposesSingleRow(): void
|
||||
{
|
||||
$result = Transpose::matrix([[1, 2, 3]]);
|
||||
|
||||
self::assertSame([[1], [2], [3]], $result);
|
||||
}
|
||||
|
||||
public function testTransposesSingleColumn(): void
|
||||
{
|
||||
$result = Transpose::matrix([[1], [2], [3]]);
|
||||
|
||||
self::assertSame([[1, 2, 3]], $result);
|
||||
}
|
||||
|
||||
public function testEmptyInputReturnsEmpty(): void
|
||||
{
|
||||
self::assertSame([], Transpose::matrix([]));
|
||||
}
|
||||
|
||||
public function testAssociativeKeysAreStripped(): void
|
||||
{
|
||||
$result = Transpose::matrix([
|
||||
['a' => 1, 'b' => 2],
|
||||
['a' => 3, 'b' => 4],
|
||||
]);
|
||||
|
||||
self::assertSame([[1, 3], [2, 4]], $result);
|
||||
}
|
||||
|
||||
public function testJaggedArrayThrows(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
|
||||
Transpose::matrix([[1, 2, 3], [4, 5]]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tez\Utils\Tests\Arr;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Tez\Utils\Arr\UniqueBy;
|
||||
|
||||
final class UniqueByTest extends TestCase
|
||||
{
|
||||
public function testFirstWinsByDefault(): void
|
||||
{
|
||||
$items = [
|
||||
['id' => 1, 'name' => 'Alice'],
|
||||
['id' => 2, 'name' => 'Bob'],
|
||||
['id' => 1, 'name' => 'Alice2'],
|
||||
];
|
||||
|
||||
$result = UniqueBy::filter($items, fn($item) => $item['id']);
|
||||
|
||||
self::assertCount(2, $result);
|
||||
self::assertSame('Alice', $result[0]['name']);
|
||||
}
|
||||
|
||||
public function testLastWins(): void
|
||||
{
|
||||
$items = [
|
||||
['id' => 1, 'name' => 'Alice'],
|
||||
['id' => 2, 'name' => 'Bob'],
|
||||
['id' => 1, 'name' => 'Alice2'],
|
||||
];
|
||||
|
||||
$result = UniqueBy::filter($items, fn($item) => $item['id'], lastWins: true);
|
||||
|
||||
// Bob's only occurrence is at position 1, Alice2's last occurrence is at position 2
|
||||
// → order reflects last-occurrence position in the original array
|
||||
self::assertCount(2, $result);
|
||||
self::assertSame('Bob', $result[0]['name']);
|
||||
self::assertSame('Alice2', $result[1]['name']);
|
||||
}
|
||||
|
||||
public function testLastWinsPreservesOriginalOrder(): void
|
||||
{
|
||||
$items = [
|
||||
['id' => 1, 'name' => 'A'],
|
||||
['id' => 2, 'name' => 'B'],
|
||||
['id' => 1, 'name' => 'C'],
|
||||
['id' => 2, 'name' => 'D'],
|
||||
];
|
||||
|
||||
$result = UniqueBy::filter($items, fn($item) => $item['id'], lastWins: true);
|
||||
|
||||
self::assertCount(2, $result);
|
||||
self::assertSame('C', $result[0]['name']);
|
||||
self::assertSame('D', $result[1]['name']);
|
||||
}
|
||||
|
||||
public function testReIndexedByDefault(): void
|
||||
{
|
||||
$items = [10 => 'a', 20 => 'b', 30 => 'a'];
|
||||
|
||||
$result = UniqueBy::filter($items, fn($v) => $v);
|
||||
|
||||
self::assertSame([0 => 'a', 1 => 'b'], $result);
|
||||
}
|
||||
|
||||
public function testPreserveKeys(): void
|
||||
{
|
||||
$items = [10 => 'a', 20 => 'b', 30 => 'a'];
|
||||
|
||||
$result = UniqueBy::filter($items, fn($v) => $v, preserveKeys: true);
|
||||
|
||||
self::assertArrayHasKey(10, $result);
|
||||
self::assertArrayHasKey(20, $result);
|
||||
self::assertArrayNotHasKey(30, $result);
|
||||
}
|
||||
|
||||
public function testPreserveKeysWithLastWins(): void
|
||||
{
|
||||
$items = [10 => 'a', 20 => 'b', 30 => 'a'];
|
||||
|
||||
$result = UniqueBy::filter($items, fn($v) => $v, lastWins: true, preserveKeys: true);
|
||||
|
||||
self::assertArrayNotHasKey(10, $result);
|
||||
self::assertArrayHasKey(20, $result);
|
||||
self::assertArrayHasKey(30, $result);
|
||||
}
|
||||
|
||||
public function testEmptyArrayReturnsEmpty(): void
|
||||
{
|
||||
$result = UniqueBy::filter([], fn($v) => $v);
|
||||
|
||||
self::assertSame([], $result);
|
||||
}
|
||||
|
||||
public function testAllUniqueReturnsAllItems(): void
|
||||
{
|
||||
$items = [1, 2, 3, 4];
|
||||
|
||||
$result = UniqueBy::filter($items, fn($v) => $v);
|
||||
|
||||
self::assertSame([1, 2, 3, 4], $result);
|
||||
}
|
||||
|
||||
public function testAllDuplicatesReturnsSingleItem(): void
|
||||
{
|
||||
$items = ['a', 'a', 'a'];
|
||||
|
||||
$result = UniqueBy::filter($items, fn($v) => $v);
|
||||
|
||||
self::assertSame(['a'], $result);
|
||||
}
|
||||
|
||||
public function testCallbackTransformation(): void
|
||||
{
|
||||
$items = ['Alice', 'alice', 'BOB', 'Bob'];
|
||||
|
||||
$result = UniqueBy::filter($items, fn($v) => strtolower($v));
|
||||
|
||||
self::assertSame(['Alice', 'BOB'], $result);
|
||||
}
|
||||
|
||||
public function testCallbackOnObjects(): void
|
||||
{
|
||||
$a = new \stdClass();
|
||||
$a->id = 1;
|
||||
$b = new \stdClass();
|
||||
$b->id = 2;
|
||||
$c = new \stdClass();
|
||||
$c->id = 1;
|
||||
|
||||
$result = UniqueBy::filter([$a, $b, $c], fn($o) => $o->id);
|
||||
|
||||
self::assertCount(2, $result);
|
||||
self::assertSame($a, $result[0]);
|
||||
self::assertSame($b, $result[1]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tez\Utils\Tests\Arr;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Tez\Utils\Arr\Wrap;
|
||||
|
||||
final class WrapTest extends TestCase
|
||||
{
|
||||
public function testWrapsScalarInArray(): void
|
||||
{
|
||||
self::assertSame(['hello'], Wrap::ensure('hello'));
|
||||
self::assertSame([42], Wrap::ensure(42));
|
||||
self::assertSame([3.14], Wrap::ensure(3.14));
|
||||
self::assertSame([true], Wrap::ensure(true));
|
||||
}
|
||||
|
||||
public function testReturnsArrayAsIs(): void
|
||||
{
|
||||
self::assertSame([1, 2, 3], Wrap::ensure([1, 2, 3]));
|
||||
self::assertSame(['a' => 1], Wrap::ensure(['a' => 1]));
|
||||
self::assertSame([], Wrap::ensure([]));
|
||||
}
|
||||
|
||||
public function testNullReturnsEmptyArray(): void
|
||||
{
|
||||
self::assertSame([], Wrap::ensure(null));
|
||||
}
|
||||
|
||||
public function testObjectIsWrapped(): void
|
||||
{
|
||||
$obj = new \stdClass();
|
||||
$result = Wrap::ensure($obj);
|
||||
|
||||
self::assertCount(1, $result);
|
||||
self::assertSame($obj, $result[0]);
|
||||
}
|
||||
|
||||
public function testAssociativeArrayIsReturnedAsIs(): void
|
||||
{
|
||||
$input = ['key' => 'value', 'other' => 42];
|
||||
$result = Wrap::ensure($input);
|
||||
|
||||
self::assertSame($input, $result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tez\Utils\Tests\Arr;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Tez\Utils\Arr\Zip;
|
||||
|
||||
final class ZipTest extends TestCase
|
||||
{
|
||||
public function testZipEqualLengthArrays(): void
|
||||
{
|
||||
$result = Zip::zip([1, 2, 3], ['a', 'b', 'c']);
|
||||
|
||||
self::assertSame([[1, 'a'], [2, 'b'], [3, 'c']], $result);
|
||||
}
|
||||
|
||||
public function testZipPadsShorterArrayWithNull(): void
|
||||
{
|
||||
$result = Zip::zip([1, 2, 3], ['a', 'b']);
|
||||
|
||||
self::assertSame([[1, 'a'], [2, 'b'], [3, null]], $result);
|
||||
}
|
||||
|
||||
public function testZipThreeArrays(): void
|
||||
{
|
||||
$result = Zip::zip([1, 2], ['a', 'b'], [true, false]);
|
||||
|
||||
self::assertSame([[1, 'a', true], [2, 'b', false]], $result);
|
||||
}
|
||||
|
||||
public function testZipNoArraysReturnsEmpty(): void
|
||||
{
|
||||
self::assertSame([], Zip::zip());
|
||||
}
|
||||
|
||||
public function testZipEmptyArraysReturnEmpty(): void
|
||||
{
|
||||
self::assertSame([], Zip::zip([], []));
|
||||
}
|
||||
|
||||
public function testShortestStopsAtSmallest(): void
|
||||
{
|
||||
$result = Zip::shortest([1, 2, 3], ['a', 'b']);
|
||||
|
||||
self::assertSame([[1, 'a'], [2, 'b']], $result);
|
||||
}
|
||||
|
||||
public function testShortestWithEqualLengths(): void
|
||||
{
|
||||
$result = Zip::shortest([1, 2], ['a', 'b']);
|
||||
|
||||
self::assertSame([[1, 'a'], [2, 'b']], $result);
|
||||
}
|
||||
|
||||
public function testShortestWithEmptyArrayReturnsEmpty(): void
|
||||
{
|
||||
self::assertSame([], Zip::shortest([1, 2, 3], []));
|
||||
}
|
||||
|
||||
public function testShortestNoArraysReturnsEmpty(): void
|
||||
{
|
||||
self::assertSame([], Zip::shortest());
|
||||
}
|
||||
|
||||
public function testZipStripsAssociativeKeys(): void
|
||||
{
|
||||
$result = Zip::zip(['a' => 1, 'b' => 2], ['x' => 10, 'y' => 20]);
|
||||
|
||||
self::assertSame([[1, 10], [2, 20]], $result);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user