diff --git a/.github/scripts/composer-audit-guard.php b/.github/scripts/composer-audit-guard.php deleted file mode 100644 index a1b1cdb..0000000 --- a/.github/scripts/composer-audit-guard.php +++ /dev/null @@ -1,85 +0,0 @@ - ['pipe', 'r'], - 1 => ['pipe', 'w'], - 2 => ['pipe', 'w'], -]; - -$process = proc_open($command, $descriptorSpec, $pipes); - -if (! \is_resource($process)) { - fwrite(STDERR, "Failed to start composer audit process.\n"); - exit(1); -} - -fclose($pipes[0]); -$stdout = stream_get_contents($pipes[1]) ?: ''; -$stderr = stream_get_contents($pipes[2]) ?: ''; -fclose($pipes[1]); -fclose($pipes[2]); - -$exitCode = proc_close($process); - -/** @var array|null $decoded */ -$decoded = json_decode($stdout, true); - -if (! \is_array($decoded)) { - fwrite(STDERR, "Unable to parse composer audit JSON output.\n"); - if (trim($stdout) !== '') { - fwrite(STDERR, $stdout . "\n"); - } - if (trim($stderr) !== '') { - fwrite(STDERR, $stderr . "\n"); - } - - exit($exitCode !== 0 ? $exitCode : 1); -} - -$advisories = $decoded['advisories'] ?? []; -$abandoned = $decoded['abandoned'] ?? []; - -$advisoryCount = 0; - -if (\is_array($advisories)) { - foreach ($advisories as $entries) { - if (\is_array($entries)) { - $advisoryCount += \count($entries); - } - } -} - -$abandonedPackages = []; - -if (\is_array($abandoned)) { - foreach ($abandoned as $package => $replacement) { - if (\is_string($package) && $package !== '') { - $abandonedPackages[$package] = $replacement; - } - } -} - -echo sprintf( - "Composer audit summary: %d advisories, %d abandoned packages.\n", - $advisoryCount, - \count($abandonedPackages), -); - -if ($abandonedPackages !== []) { - fwrite(STDERR, "Warning: abandoned packages detected (non-blocking):\n"); - foreach ($abandonedPackages as $package => $replacement) { - $target = \is_string($replacement) && $replacement !== '' ? $replacement : 'none'; - fwrite(STDERR, sprintf(" - %s (replacement: %s)\n", $package, $target)); - } -} - -if ($advisoryCount > 0) { - fwrite(STDERR, "Security vulnerabilities detected by composer audit.\n"); - exit(1); -} - -exit(0); diff --git a/.github/scripts/phpstan-sarif.php b/.github/scripts/phpstan-sarif.php deleted file mode 100644 index 2b01b26..0000000 --- a/.github/scripts/phpstan-sarif.php +++ /dev/null @@ -1,178 +0,0 @@ - [sarif-output] - */ - -$argv = $_SERVER['argv'] ?? []; -$input = $argv[1] ?? ''; -$output = $argv[2] ?? 'phpstan-results.sarif'; - -if (! is_string($input) || $input === '') { - fwrite(STDERR, "Error: missing input file.\n"); - fwrite(STDERR, "Usage: php .github/scripts/phpstan-sarif.php [sarif-output]\n"); - exit(2); -} - -if (! is_file($input) || ! is_readable($input)) { - fwrite(STDERR, "Error: input file not found or unreadable: {$input}\n"); - exit(2); -} - -$raw = file_get_contents($input); -if ($raw === false) { - fwrite(STDERR, "Error: failed to read input file: {$input}\n"); - exit(2); -} - -$decoded = json_decode($raw, true); -if (! is_array($decoded)) { - fwrite(STDERR, "Error: input is not valid JSON.\n"); - exit(2); -} - -/** - * @return non-empty-string - */ -function normalizeUri(string $path): string -{ - $normalized = str_replace('\\', '/', $path); - $cwd = getcwd(); - - if (is_string($cwd) && $cwd !== '') { - $cwd = rtrim(str_replace('\\', '/', $cwd), '/'); - - if (preg_match('/^[A-Za-z]:\//', $normalized) === 1) { - if (stripos($normalized, $cwd . '/') === 0) { - $normalized = substr($normalized, strlen($cwd) + 1); - } - } elseif (str_starts_with($normalized, '/')) { - if (str_starts_with($normalized, $cwd . '/')) { - $normalized = substr($normalized, strlen($cwd) + 1); - } - } - } - - $normalized = ltrim($normalized, './'); - - return $normalized === '' ? 'unknown.php' : $normalized; -} - -$results = []; -$rules = []; - -$globalErrors = $decoded['errors'] ?? []; -if (is_array($globalErrors)) { - foreach ($globalErrors as $error) { - if (! is_string($error) || $error === '') { - continue; - } - - $ruleId = 'phpstan.internal'; - $rules[$ruleId] = true; - $results[] = [ - 'ruleId' => $ruleId, - 'level' => 'error', - 'message' => [ - 'text' => $error, - ], - ]; - } -} - -$files = $decoded['files'] ?? []; -if (is_array($files)) { - foreach ($files as $filePath => $fileData) { - if (! is_string($filePath) || ! is_array($fileData)) { - continue; - } - - $messages = $fileData['messages'] ?? []; - if (! is_array($messages)) { - continue; - } - - foreach ($messages as $messageData) { - if (! is_array($messageData)) { - continue; - } - - $messageText = (string) ($messageData['message'] ?? 'PHPStan issue'); - $line = (int) ($messageData['line'] ?? 1); - $identifier = (string) ($messageData['identifier'] ?? ''); - $ruleId = $identifier !== '' ? $identifier : 'phpstan.issue'; - - if ($line < 1) { - $line = 1; - } - - $rules[$ruleId] = true; - $results[] = [ - 'ruleId' => $ruleId, - 'level' => 'error', - 'message' => [ - 'text' => $messageText, - ], - 'locations' => [[ - 'physicalLocation' => [ - 'artifactLocation' => [ - 'uri' => normalizeUri($filePath), - ], - 'region' => [ - 'startLine' => $line, - ], - ], - ]], - ]; - } - } -} - -$ruleDescriptors = []; -$ruleIds = array_keys($rules); -sort($ruleIds); - -foreach ($ruleIds as $ruleId) { - $ruleDescriptors[] = [ - 'id' => $ruleId, - 'name' => $ruleId, - 'shortDescription' => [ - 'text' => $ruleId, - ], - ]; -} - -$sarif = [ - '$schema' => 'https://json.schemastore.org/sarif-2.1.0.json', - 'version' => '2.1.0', - 'runs' => [[ - 'tool' => [ - 'driver' => [ - 'name' => 'PHPStan', - 'informationUri' => 'https://phpstan.org/', - 'rules' => $ruleDescriptors, - ], - ], - 'results' => $results, - ]], -]; - -$encoded = json_encode($sarif, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); -if (! is_string($encoded)) { - fwrite(STDERR, "Error: failed to encode SARIF JSON.\n"); - exit(2); -} - -$written = file_put_contents($output, $encoded . PHP_EOL); -if ($written === false) { - fwrite(STDERR, "Error: failed to write output file: {$output}\n"); - exit(2); -} - -fwrite(STDOUT, sprintf("SARIF generated: %s (%d findings)\n", $output, count($results))); -exit(0); diff --git a/.github/scripts/syntax.php b/.github/scripts/syntax.php deleted file mode 100644 index 043bf53..0000000 --- a/.github/scripts/syntax.php +++ /dev/null @@ -1,109 +0,0 @@ -isFile()) { - continue; - } - - $filename = $entry->getFilename(); - if (! str_ends_with($filename, '.php')) { - continue; - } - - $files[] = $entry->getPathname(); - } -} - -$files = array_values(array_unique($files)); -sort($files); - -if ($files === []) { - fwrite(STDOUT, "No PHP files found.\n"); - exit(0); -} - -$failed = []; - -foreach ($files as $file) { - $command = [PHP_BINARY, '-d', 'display_errors=1', '-l', $file]; - $descriptorSpec = [ - 1 => ['pipe', 'w'], - 2 => ['pipe', 'w'], - ]; - - $process = proc_open($command, $descriptorSpec, $pipes); - - if (! is_resource($process)) { - $failed[] = [$file, 'Could not start PHP lint process']; - continue; - } - - $stdout = stream_get_contents($pipes[1]); - fclose($pipes[1]); - - $stderr = stream_get_contents($pipes[2]); - fclose($pipes[2]); - - $exitCode = proc_close($process); - - if ($exitCode !== 0) { - $output = trim((string) $stdout . "\n" . (string) $stderr); - $failed[] = [$file, $output !== '' ? $output : 'Unknown lint failure']; - } -} - -if ($failed === []) { - fwrite(STDOUT, sprintf("Syntax OK: %d PHP files checked.\n", count($files))); - exit(0); -} - -fwrite(STDERR, sprintf("Syntax errors in %d file(s):\n", count($failed))); - -foreach ($failed as [$file, $error]) { - fwrite(STDERR, "- {$file}\n{$error}\n"); -} - -exit(1); diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml deleted file mode 100644 index 001aea0..0000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,128 +0,0 @@ -name: "Security & Standards" - -on: - schedule: - - cron: '0 0 * * 0' - push: - branches: [ "main", "master" ] - pull_request: - branches: [ "main", "master", "develop", "development" ] - -jobs: - prepare: - name: Prepare CI matrix - runs-on: ubuntu-latest - outputs: - php_versions: ${{ steps.matrix.outputs.php_versions }} - dependency_versions: ${{ steps.matrix.outputs.dependency_versions }} - steps: - - name: Define shared matrix values - id: matrix - run: | - echo 'php_versions=["8.4","8.5"]' >> "$GITHUB_OUTPUT" - echo 'dependency_versions=["prefer-lowest","prefer-stable"]' >> "$GITHUB_OUTPUT" - - run: - needs: prepare - runs-on: ${{ matrix.operating-system }} - strategy: - matrix: - operating-system: [ ubuntu-latest ] - php-versions: ${{ fromJson(needs.prepare.outputs.php_versions) }} - dependency-version: ${{ fromJson(needs.prepare.outputs.dependency_versions) }} - - name: Code Analysis - PHP ${{ matrix.php-versions }} - ${{ matrix.dependency-version }} - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php-versions }} - tools: composer:v2 - coverage: xdebug - - - name: Check PHP Version - run: php -v - - - name: Validate Composer - run: composer validate --strict - - - name: Resolve dependencies (${{ matrix.dependency-version }}) - run: composer update --no-interaction --prefer-dist --no-progress --${{ matrix.dependency-version }} - - - name: Test - run: | - composer test:syntax - composer test:code - composer test:lint - composer test:sniff - composer test:refactor - if [ "${{ matrix.dependency-version }}" != "prefer-lowest" ]; then - composer test:static - fi - if [ "${{ matrix.dependency-version }}" != "prefer-lowest" ]; then - composer test:security - fi - - analyze: - needs: prepare - name: Security Analysis - PHP ${{ matrix.php-versions }} - runs-on: ubuntu-latest - strategy: - matrix: - php-versions: ${{ fromJson(needs.prepare.outputs.php_versions) }} - permissions: - security-events: write - actions: read - contents: read - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php-versions }} - tools: composer:v2 - coverage: xdebug - - - name: Install dependencies - run: composer install --no-interaction --prefer-dist --no-progress - - - name: Composer Audit (Release Guard) - run: composer release:audit - - - name: Quality Gate (PHPStan) - run: composer test:static - - - name: Security Gate (Psalm) - run: composer test:security - - - name: Run PHPStan (Code Scanning) - run: | - php ./vendor/bin/phpstan analyse --configuration=phpstan.neon.dist --memory-limit=1G --no-progress --error-format=json > phpstan-results.json || true - php .github/scripts/phpstan-sarif.php phpstan-results.json phpstan-results.sarif - continue-on-error: true - - - name: Upload PHPStan Results - uses: github/codeql-action/upload-sarif@v4 - with: - sarif_file: phpstan-results.sarif - category: "phpstan-${{ matrix.php-versions }}" - if: always() && hashFiles('phpstan-results.sarif') != '' - - # Run Psalm (Deep Taint Analysis) - - name: Run Psalm Security Scan - run: | - php ./vendor/bin/psalm --config=psalm.xml --security-analysis --threads=1 --report=psalm-results.sarif || true - continue-on-error: true - - - name: Upload Psalm Results - uses: github/codeql-action/upload-sarif@v4 - with: - sarif_file: psalm-results.sarif - category: "psalm-${{ matrix.php-versions }}" - if: always() && hashFiles('psalm-results.sarif') != '' diff --git a/.github/workflows/security-standards.yml b/.github/workflows/security-standards.yml new file mode 100644 index 0000000..dbac569 --- /dev/null +++ b/.github/workflows/security-standards.yml @@ -0,0 +1,40 @@ +name: "Security & Standards" + +on: + schedule: + - cron: "0 0 * * 0" + push: + branches: [ "main", "master" ] + pull_request: + branches: [ "main", "master", "develop", "development" ] + +jobs: + phpforge: + uses: infocyph/phpforge/.github/workflows/security-standards.yml@main + permissions: + security-events: write + actions: read + contents: read + with: + php_versions: '["8.4","8.5"]' + dependency_versions: '["prefer-lowest","prefer-stable"]' + php_extensions: "" + composer_flags: "" + phpstan_memory_limit: "1G" + psalm_threads: "1" + run_analysis: true + run_svg_report: true + fail_on_skipped_tests: false + enable_redis_service: false + enable_valkey_service: false + enable_memcached_service: false + enable_postgres_service: false + enable_mysql_service: false + enable_scylladb_service: false + enable_elasticsearch_service: false + enable_mongodb_service: false + service_db_name: "phpforge" + service_db_user: "phpforge" + service_db_password: "phpforge" + artifact_retention_days: 61 + diff --git a/README.md b/README.md index 917c952..6eca2b4 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # ArrayKit -[![Security & Standards](https://github.com/infocyph/arraykit/actions/workflows/build.yml/badge.svg)](https://github.com/infocyph/arraykit/actions/workflows/build.yml) +[![Security & Standards](https://github.com/infocyph/arraykit/actions/workflows/security-standards.yml/badge.svg)](https://github.com/infocyph/arraykit/actions/workflows/security-standards.yml) ![Packagist Downloads](https://img.shields.io/packagist/dt/infocyph/arraykit?color=green\&link=https%3A%2F%2Fpackagist.org%2Fpackages%2Finfocyph%2Farraykit) [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://opensource.org/licenses/MIT) ![Packagist Version](https://img.shields.io/packagist/v/infocyph/arraykit) @@ -31,10 +31,11 @@ real-world PHP projects. | Helper | Description | |---------------------|----------------------------------------------------------------------------------------------------| -| **ArraySingle** | Helpers for single-dimensional arrays (detect list/assoc, filter, paginate, duplicates, averages). | -| **ArrayMulti** | Helpers for multi-dimensional arrays (flatten, collapse, depth, recursive sort, filter). | -| **DotNotation** | Get/set/remove values using dot keys; flatten & expand nested arrays with dot keys. | +| **ArraySingle** | Helpers for single-dimensional arrays (set ops, mapWithKeys, countBy, min/max, paginate, duplicates, averages). | +| **ArrayMulti** | Helpers for multi-dimensional arrays (flatten, collapse, depth, keyBy/indexBy, firstWhere, recursive sort/filter). | +| **DotNotation** | Get/set/remove values using dot keys; wildcard support; escaped literal-dot paths; flatten & expand. | | **BaseArrayHelper** | Internal shared base for consistent API across helpers. | +| **ArraySharedOps** | Internal shared operations used by `ArraySingle` and `ArrayMulti` (`each/every/partition/skip*`). | ### ➤ Config System @@ -49,7 +50,7 @@ real-world PHP projects. | Class | Description | |-------------------------|--------------------------------------------------------------------------------------------| -| **Collection** | OOP array wrapper implementing `ArrayAccess`, `Iterator`, `Countable`, `JsonSerializable`. | +| **Collection** | OOP array wrapper implementing `ArrayAccess`, `IteratorAggregate`, `Countable`, `JsonSerializable`. | | **HookedCollection** | Extends `Collection` with **on-get/on-set hooks** for real-time transformation of values. | | **Pipeline** | Functional-style pipeline for chaining operations on collections. | | **BaseCollectionTrait** | Shared collection behavior. | @@ -149,12 +150,16 @@ $user = [ // Get value $name = DotNotation::get($user, 'profile.name'); // Alice +$literal = DotNotation::get(['profile.name' => 'flat'], 'profile\\.name'); // flat // Set value DotNotation::set($user, 'profile.email', 'alice@example.com'); // Flatten $flat = DotNotation::flatten($user); + +// wildcard set +DotNotation::set($user, 'users.*.active', true); // [ 'profile.name' => 'Alice', 'profile.email' => 'alice@example.com' ] ``` diff --git a/benchmarks/CoreBench.php b/benchmarks/CoreBench.php index fc552e9..2dac984 100644 --- a/benchmarks/CoreBench.php +++ b/benchmarks/CoreBench.php @@ -23,8 +23,13 @@ final class CoreBench private array $dot = []; private array $nested = []; + + private array $queryRows = []; + private array $single = []; + private array $singleAssoc = []; + public function setUp(): void { $this->single = [ @@ -33,6 +38,17 @@ public function setUp(): void 50, 51, 52, 53, 54, 55, 56, 57, ]; + $this->singleAssoc = [ + 'a' => 10, + 'b' => 20, + 'c' => 30, + 'd' => 40, + 'e' => 50, + 'f' => 60, + 'g' => 70, + 'h' => 80, + ]; + $this->nested = [ ['id' => 1, 'name' => 'Alice', 'scores' => [9, 8, 7]], ['id' => 2, 'name' => 'Bob', 'scores' => [6, 7, 8]], @@ -41,6 +57,14 @@ public function setUp(): void ['id' => 5, 'name' => 'Evan', 'scores' => [8, 8, 8]], ]; + $this->queryRows = [ + ['id' => 1, 'role' => 'admin'], + ['id' => 2, 'role' => null], + ['id' => 3, 'role' => 'editor'], + ['id' => 4], + ['id' => 5, 'role' => 'viewer'], + ]; + $this->dot = [ 'app' => ['name' => 'ArrayKit', 'env' => 'local'], 'db' => [ @@ -49,18 +73,67 @@ public function setUp(): void 'options' => ['timeout' => 5, 'ssl' => false], ], 'cache' => ['driver' => 'file', 'prefix' => 'arraykit'], + 'service.name' => 'arraykit-service', ]; $this->config = new Config(); $this->config->loadArray($this->dot); } + #[Subject] + public function benchArrayMultiEvery(): void + { + ArrayMulti::every($this->nested, static fn(array $row): bool => isset($row['id'])); + } + #[Subject] public function benchArrayMultiFlatten(): void { ArrayMulti::flatten($this->nested); } + #[Subject] + public function benchArrayMultiKeyBy(): void + { + ArrayMulti::keyBy($this->nested, 'id'); + } + + #[Subject] + public function benchArrayMultiSkipUntil(): void + { + ArrayMulti::skipUntil($this->nested, static fn(array $row): bool => ($row['id'] ?? 0) >= 3); + } + + #[Subject] + public function benchArrayMultiWhereInNull(): void + { + ArrayMulti::whereIn($this->queryRows, 'role', [null], true); + } + + #[Subject] + public function benchArraySingleCountBy(): void + { + ArraySingle::countBy($this->single); + } + + #[Subject] + public function benchArraySingleNth(): void + { + ArraySingle::nth($this->single, 3, 2); + } + + #[Subject] + public function benchArraySinglePartition(): void + { + ArraySingle::partition($this->singleAssoc, static fn(int $value): bool => $value >= 40); + } + + #[Subject] + public function benchArraySingleSkipWhile(): void + { + ArraySingle::skipWhile($this->single, static fn(int $value): bool => $value < 50); + } + #[Subject] public function benchArraySingleUnique(): void { @@ -73,6 +146,12 @@ public function benchConfigGet(): void $this->config->get('db.options.timeout'); } + #[Subject] + public function benchDotNotationEscapedKeyGet(): void + { + DotNotation::get($this->dot, 'service\\.name'); + } + #[Subject] public function benchDotNotationGet(): void { diff --git a/captainhook.json b/captainhook.json index fa19900..782a292 100644 --- a/captainhook.json +++ b/captainhook.json @@ -15,11 +15,15 @@ "options": [] }, { - "action": "composer release:audit", + "action": "composer normalize --dry-run", "options": [] }, { - "action": "composer tests", + "action": "composer ic:release:audit", + "options": [] + }, + { + "action": "composer ic:ci", "options": [] } ] diff --git a/composer.json b/composer.json index 91ea47d..64cd200 100644 --- a/composer.json +++ b/composer.json @@ -1,8 +1,8 @@ { "name": "infocyph/arraykit", "description": "A Collection of useful PHP array functions.", - "type": "library", "license": "MIT", + "type": "library", "keywords": [ "collection", "array", @@ -14,92 +14,36 @@ "email": "abmmhasan@gmail.com" } ], + "require": { + "php": ">=8.4" + }, + "require-dev": { + "infocyph/phpforge": "dev-main" + }, + "minimum-stability": "stable", + "prefer-stable": true, "autoload": { - "files": [ - "src/functions.php" - ], "psr-4": { "Infocyph\\ArrayKit\\": "src/" - } + }, + "files": [ + "src/namespaced-functions.php", + "src/functions.php" + ] }, "autoload-dev": { "psr-4": { "Infocyph\\ArrayKit\\Tests\\": "tests/" } }, - "require": { - "php": ">=8.4" - }, - "require-dev": { - "captainhook/captainhook": "^5.29.2", - "laravel/pint": "^1.29", - "pestphp/pest": "^4.5", - "pestphp/pest-plugin-drift": "^4.1", - "phpbench/phpbench": "^1.6", - "phpstan/phpstan": "^2.1", - "rector/rector": "^2.4.1", - "squizlabs/php_codesniffer": "^4.0.1", - "symfony/var-dumper": "^7.3 || ^8.0.8", - "tomasvotruba/cognitive-complexity": "^1.1", - "vimeo/psalm": "^6.16.1" - }, - "scripts": { - "test:syntax": "@php .github/scripts/syntax.php src tests benchmarks examples", - "test:code": "@php vendor/bin/pest", - "test:lint": "@php vendor/bin/pint --test", - "test:sniff": "@php vendor/bin/phpcs --standard=phpcs.xml.dist --report=full", - "test:static": "@php vendor/bin/phpstan analyse --configuration=phpstan.neon.dist --memory-limit=1G", - "test:security": "@php vendor/bin/psalm --config=psalm.xml --security-analysis --threads=1 --no-cache", - "test:refactor": "@php vendor/bin/rector process --dry-run --debug", - "test:bench": "@php vendor/bin/phpbench run --config=phpbench.json --report=aggregate", - "test:details": [ - "@test:syntax", - "@test:code", - "@test:lint", - "@test:sniff", - "@test:static", - "@test:security", - "@test:refactor" - ], - "test:all": [ - "@test:syntax", - "@php vendor/bin/pest --parallel --processes=10", - "@php vendor/bin/pint --test", - "@php vendor/bin/phpcs --standard=phpcs.xml.dist --report=summary", - "@php vendor/bin/phpstan analyse --configuration=phpstan.neon.dist --memory-limit=1G --no-progress --debug", - "@php vendor/bin/psalm --config=psalm.xml --show-info=false --security-analysis --threads=1 --no-progress --no-cache", - "@php vendor/bin/rector process --dry-run --debug" - ], - "release:audit": "@php .github/scripts/composer-audit-guard.php", - "release:guard": [ - "@composer validate --strict", - "@release:audit", - "@tests" - ], - "process:lint": "@php vendor/bin/pint", - "process:sniff:fix": "@php vendor/bin/phpcbf --standard=phpcs.xml.dist --runtime-set ignore_errors_on_exit 1", - "process:refactor": "@php vendor/bin/rector process", - "process:all": [ - "@process:refactor", - "@process:lint", - "@process:sniff:fix" - ], - "bench:run": "@php vendor/bin/phpbench run --config=phpbench.json --report=aggregate", - "bench:quick": "@php vendor/bin/phpbench run --config=phpbench.json --report=aggregate --revs=10 --iterations=3 --warmup=1", - "bench:chart": "@php vendor/bin/phpbench run --config=phpbench.json --report=chart", - "tests": "@test:all", - "process": "@process:all", - "benchmark": "@bench:run", - "post-autoload-dump": "captainhook install --only-enabled -nf" - }, - "minimum-stability": "stable", - "prefer-stable": true, "config": { - "sort-packages": true, - "optimize-autoloader": true, - "classmap-authoritative": true, "allow-plugins": { + "ergebnis/composer-normalize": true, + "infocyph/phpforge": true, "pestphp/pest-plugin": true - } + }, + "classmap-authoritative": true, + "optimize-autoloader": true, + "sort-packages": true } } diff --git a/docs/array-helpers.rst b/docs/array-helpers.rst index bdf7fc1..b59f5be 100644 --- a/docs/array-helpers.rst +++ b/docs/array-helpers.rst @@ -6,6 +6,7 @@ ArrayKit ships static helpers grouped by data shape: - ``ArraySingle`` for one-dimensional arrays - ``ArrayMulti`` for nested arrays / row collections - ``BaseArrayHelper`` for lower-level shared operations +- ``ArraySharedOps`` for shared iteration/partition/skip and key-normalization internals If you prefer one entry point, use ``Infocyph\ArrayKit\ArrayKit``: @@ -84,6 +85,7 @@ ArraySingle: Positional Operations $slice = ArraySingle::slice($arr, 1, 3); // [1 => 20, 2 => 30, 3 => 40] $skip = ArraySingle::skip($arr, 2); // [2 => 30, 3 => 40, ...] $nth = ArraySingle::nth($arr, 2); // [10, 30, 50] + $nthOffset = ArraySingle::nth($arr, 2, 2); // [30, 50] $page = ArraySingle::paginate($arr, 2, 2); // page 2 => [2 => 30, 3 => 40] $chunks = ArraySingle::chunk($arr, 2); // [[10,20], [30,40], [50,60]] $until40 = ArraySingle::skipUntil($arr, fn ($v) => $v === 40); @@ -176,6 +178,8 @@ ArrayMulti: Grouping, Ordering, and Projection ]; $grouped = ArrayMulti::groupBy($rows, 'team'); + $indexed = ArrayMulti::keyBy($rows, 'team'); + $counts = ArrayMulti::countBy($rows, 'team'); $sorted = ArrayMulti::sortBy($rows, 'score', true); // desc $sortedRecursive = ArrayMulti::sortRecursive($rows); $scores = ArrayMulti::pluck($rows, 'score'); // [10,30,20] @@ -196,8 +200,12 @@ ArrayMulti: Row Set Operations ]; $unique = ArrayMulti::unique($rows); + $minScore = ArrayMulti::min($rows, 'id'); + $maxScore = ArrayMulti::max($rows, 'id'); + $firstHigh = ArrayMulti::firstWhere($rows, 'id', '>=', 2); [$passed, $failed] = ArrayMulti::partition($rows, fn ($row) => $row['id'] === 1); $mapped = ArrayMulti::map($rows, fn ($row) => $row['name']); + $mappedKeys = ArrayMulti::mapWithKeys($rows, fn ($row) => [$row['id'] => $row['name']]); $reduced = ArrayMulti::reduce($rows, fn ($carry, $row) => $carry + $row['id'], 0); $sumById = ArrayMulti::sum($rows, 'id'); @@ -226,10 +234,54 @@ BaseArrayHelper $all = BaseArrayHelper::all([1, 2, 3], fn ($v) => $v > 0); // true $key = BaseArrayHelper::findKey(['x' => 3], fn ($v) => $v === 3); // x +ArraySharedOps (Internal) +------------------------- + +``ArraySharedOps`` is internal infrastructure shared by ``ArraySingle`` and ``ArrayMulti``. +Prefer consuming the public helper APIs directly (``ArraySingle``, ``ArrayMulti``, ``BaseArrayHelper``). + +Behavior Matrix +--------------- + +.. list-table:: + :header-rows: 1 + + * - Helper family + - Preserves keys by default + - Mutates input argument + - Dot-path support + - Wildcard support + * - ``ArraySingle`` + - yes (except ``values()``, ``unique()``, positional list helpers) + - no + - no + - no + * - ``ArrayMulti`` + - usually yes for filters/sorts; reshape methods may reindex + - no + - no + - no + * - ``DotNotation`` + - n/a (key-path accessor) + - yes for ``set/fill/forget`` via reference + - yes + - yes + +Performance Notes +----------------- + +- Direct static calls (``ArraySingle::*``, ``ArrayMulti::*``, ``DotNotation::*``) are the fastest path. +- ``ArrayKit`` facade and ``ModuleProxy`` add convenience but include dynamic-call overhead. +- Collection pipeline methods mutate the current collection; use ``copy()`` / ``immutable()`` when needed. +- Dot-notation paths are parsed and cached; escaped literal segments (for example ``service\\.name``) are supported. + Behavior Notes -------------- - Many methods preserve original keys (especially ``slice``, ``where``, ``skip`` variants). +- ``ArraySingle::isAssoc([])`` is ``false``; empty arrays are treated as non-associative. +- ``ArraySingle::nth($array, $step, $offset)`` starts at ``$offset`` then takes every ``$step`` item. - ``ArraySingle::unique()`` has loose mode (default) and strict mode. +- ``ArrayMulti::whereIn()`` / ``whereNotIn()`` treat ``null`` as a real value when the key exists. - ``ArrayMulti::where()`` uses the global ``compare()`` helper semantics for operators. - ``BaseArrayHelper::random()`` throws ``InvalidArgumentException`` when requested count exceeds array size. diff --git a/docs/collection.rst b/docs/collection.rst index ea5ac26..a61bc3a 100644 --- a/docs/collection.rst +++ b/docs/collection.rst @@ -4,7 +4,7 @@ Collections ArrayKit collections provide an object-oriented array wrapper with: - dot-notation read/write -- full ``ArrayAccess`` + ``Iterator`` + ``Countable`` behavior +- full ``ArrayAccess`` + ``IteratorAggregate`` + ``Countable`` behavior - a chainable pipeline of transformation methods - optional get/set hooks via ``HookedCollection`` @@ -89,6 +89,10 @@ Collection Utility Methods $c->merge(['c' => 3]); // now a,b,c $c->clear(); // now empty + // Immutable-style snapshots + $copy = $c->copy(); + $immutable = $c->immutable(); + Iteration and Interfaces ------------------------ @@ -139,6 +143,8 @@ Pipeline Basics Every transformation method is exposed through ``Pipeline``. You can start it either with ``process()`` or directly by calling pipeline methods on collection (via ``__call``). +Pipeline methods mutate the current collection instance and return that same instance for chaining. +Use ``copy()`` or ``immutable()`` before chaining when you need functional-style non-mutating behavior. .. code-block:: php @@ -165,6 +171,7 @@ Selection and filtering: - ``where()``, ``whereCallback()`` - ``whereIn()``, ``whereNotIn()``, ``whereNull()``, ``whereNotNull()`` - ``between()`` +- ``firstWhere()`` Slicing and positional: @@ -174,22 +181,25 @@ Slicing and positional: Structure and reshape: - ``flatten()``, ``flattenByKey()``, ``collapse()`` -- ``groupBy()``, ``pluck()``, ``transpose()`` -- ``wrap()``, ``unWrap()`` +- ``groupBy()``, ``keyBy()``, ``indexBy()``, ``pluck()``, ``transpose()`` +- ``mapWithKeys()``, ``values()``, ``rekey()`` +- ``wrap()``, ``unWrap()``, ``unwrap()`` Ordering and uniqueness: - ``sortBy()``, ``sortRecursive()``, ``shuffle()`` - ``unique()``, ``duplicates()``, ``partition()`` +- ``intersect()``, ``diff()``, ``symmetricDiff()``, ``same()`` Terminal methods (end chain with scalar/array/bool): -- ``sum()``, ``first()``, ``last()``, ``reduce()`` -- ``any()``, ``median()``, ``mode()``, ``isMultiDimensional()`` +- ``sum()``, ``min()``, ``max()``, ``first()``, ``last()``, ``reduce()`` +- ``any()``, ``countBy()``, ``median()``, ``mode()``, ``minBy()``, ``maxBy()``, ``isMultiDimensional()`` Flow-control helpers: - ``tap()``, ``pipe()``, ``when()``, ``unless()`` +- ``mergeRecursiveDistinct()``, ``replaceRecursive()``, ``overlay()`` Detailed Pipeline Examples -------------------------- diff --git a/docs/config.rst b/docs/config.rst index 67e31e3..4582ef0 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -36,6 +36,8 @@ Important behavior: - ``loadArray()`` and ``loadFile()`` only load when config is currently empty. - If already loaded, they return ``false`` and do not overwrite existing items. +- ``replace()`` always replaces in-memory config items. +- ``reload()`` replaces from array or readable file path. - Facade-based config creation is documented in :doc:`facade`. Reading Values @@ -59,6 +61,7 @@ Reading Values $has = $config->has('app.name'); // true $hasAny = $config->hasAny(['missing.path', 'queue.driver']); // true + $required = $config->getOrFail('app.name'); // throws if missing Writing Values -------------- @@ -207,6 +210,7 @@ Rules: $config->preload(['db', 'cache']); $loaded = $config->loadedNamespaces(); // ['db', 'cache'] + $isLoaded = $config->loaded('db'); // alias of isLoaded() Important behavior: @@ -214,6 +218,7 @@ Important behavior: - ``all()`` is intentionally disabled and throws. - Namespace file must return an array. - Missing namespace file returns the provided default. +- ``replace()`` and ``reload()`` reset resolved-namespace tracking. Method Summary -------------- @@ -222,15 +227,18 @@ Config methods: - ``loadFile()``, ``loadArray()``, ``all()`` - ``get()``, ``has()``, ``hasAny()`` +- ``getOrFail()`` - ``set()``, ``fill()``, ``forget()`` - ``prepend()``, ``append()`` +- ``replace()``, ``reload()`` LazyFileConfig methods: - ``get()`` (requires key) - ``has()``, ``hasAny()`` - ``set()``, ``fill()``, ``forget()`` -- ``preload()``, ``isLoaded()``, ``loadedNamespaces()`` +- ``preload()``, ``isLoaded()``, ``loaded()``, ``loadedNamespaces()`` +- ``replace()``, ``reload()`` - ``all()`` (throws by design) Hook-aware methods (Config and LazyFileConfig): diff --git a/docs/dot-notation.rst b/docs/dot-notation.rst index ebf95ca..990bf0c 100644 --- a/docs/dot-notation.rst +++ b/docs/dot-notation.rst @@ -29,6 +29,22 @@ Basic Get/Set // Replace entire array (key = null) DotNotation::set($data, null, ['fresh' => true]); +Literal Dot Keys (Escaped Paths) +-------------------------------- + +Use ``\\.`` inside a path segment to target literal key dots: + +.. code-block:: php + + 'ArrayKit']; + + $name = DotNotation::get($data, 'service\\.name'); // ArrayKit + DotNotation::set($data, 'service\\.env', 'prod'); + DotNotation::forget($data, 'service\\.env'); + Reading Multiple Keys --------------------- @@ -197,7 +213,8 @@ Behavior Notes -------------- - ``get($array, null)`` returns the full array. -- Defaults may be plain values or callables. +- Existing keys with ``null`` values return ``null`` (not the default). +- Defaults may be plain values or callables, and callables are only evaluated when path resolution fails. - Wildcard traversal in ``get`` returns arrays of matched results. - ``set`` supports wildcard paths when wildcard is the first segment. - ``forget`` supports wildcard and nested removal across arrays. diff --git a/docs/index.rst b/docs/index.rst index 375ae05..49d5d31 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -23,6 +23,7 @@ Contents collection config traits-and-helpers + migration rule-reference The feature pages above are guide-style usage docs. diff --git a/docs/migration.rst b/docs/migration.rst new file mode 100644 index 0000000..5a9d0f8 --- /dev/null +++ b/docs/migration.rst @@ -0,0 +1,30 @@ +Migration and Compatibility +=========================== + +This page highlights behavior and API additions that may affect usage patterns. + +Recent Additions +---------------- + +- ``ArrayMulti`` now includes ``keyBy()``, ``indexBy()``, ``countBy()``, ``firstWhere()``, ``mapWithKeys()``, ``min/max``, ``minBy/maxBy``, ``values()``, ``rekey()``, and deep merge helpers. +- ``ArraySingle`` now includes ``countBy()``, ``mapWithKeys()``, ``min/max``, ``minBy/maxBy``, ``values()``, ``rekey()``, set helpers (``intersect/diff/symmetricDiff/same``), and optimized strict lookups. +- ``DotNotation`` supports escaped dot-path segments (for literal key dots) and path compilation cache. +- ``Collection`` now implements ``IteratorAggregate`` semantics for safe nested iteration and provides ``copy()`` / ``immutable()`` snapshots. +- ``Config`` / ``LazyFileConfig`` now include ``replace()``, ``reload()``, and ``getOrFail()``. +- ``LazyFileConfig`` includes ``loaded()`` alias for ``isLoaded()``. +- Namespaced helper alternatives are available in ``Infocyph\ArrayKit\*`` alongside global helpers. + +Compatibility Notes +------------------- + +- ``unWrap()`` remains supported; ``unwrap()`` is now the preferred alias in helper and pipeline usage. +- Pipeline methods are mutable by design: most transformation methods update the same collection instance and return it. +- Use ``copy()`` or ``immutable()`` before pipeline operations when functional immutability is preferred. + +Recommended Upgrade Checklist +----------------------------- + +1. Prefer direct static calls (``ArraySingle`` / ``ArrayMulti`` / ``DotNotation``) for hot paths. +2. Use escaped paths (for example ``service\\.name``) when reading/writing literal dot keys. +3. Replace manual row indexing/grouping loops with ``keyBy()``, ``countBy()``, and ``firstWhere()`` where applicable. +4. Use ``getOrFail()`` for required config values in boot/runtime-critical code. diff --git a/docs/rule-reference.rst b/docs/rule-reference.rst index 6cc059f..ffe2657 100644 --- a/docs/rule-reference.rst +++ b/docs/rule-reference.rst @@ -97,6 +97,11 @@ Global Helper Functions function array_set(array &$array, string|array|null $key, mixed $value = null, bool $overwrite = true): bool function collect(mixed $data = []): Collection function chain(mixed $data): Pipeline + function Infocyph\ArrayKit\compare(mixed $retrieved, mixed $value, ?string $operator = null): bool + function Infocyph\ArrayKit\array_get(array $array, int|string|array|null $key = null, mixed $default = null): mixed + function Infocyph\ArrayKit\array_set(array &$array, string|array|null $key, mixed $value = null, bool $overwrite = true): bool + function Infocyph\ArrayKit\collect(mixed $data = []): Collection + function Infocyph\ArrayKit\chain(mixed $data): Pipeline ArrayKit Facade --------------- @@ -128,6 +133,7 @@ BaseArrayHelper public static function isMultiDimensional(mixed $array): bool public static function wrap(mixed $value): array public static function unWrap(mixed $value): mixed + public static function unwrap(mixed $value): mixed public static function haveAny(array $array, callable $callback): bool public static function isAll(array $array, callable $callback): bool public static function findKey(array $array, callable $callback): int|string|null @@ -143,6 +149,20 @@ BaseArrayHelper public static function random(array $array, ?int $number = null, bool $preserveKeys = false): mixed public static function doReject(array $array, mixed $callback): array +ArraySharedOps (Internal) +--------------------------------------- + +.. code-block:: php + + public static function each(array $array, callable $callback): array + public static function every(array $array, callable $callback): bool + public static function partition(array $array, callable $callback): array + public static function skip(array $array, int $count): array + public static function skipUntil(array $array, callable $callback): array + public static function skipWhile(array $array, callable $callback): array + public static function normalizeArrayKey(mixed $value): int|string + public static function asString(mixed $value): string + ArraySingle ----------------------------------- @@ -171,14 +191,21 @@ ArraySingle public static function search(array $array, mixed $needle): int|string|null public static function chunk(array $array, int $size, bool $preserveKeys = false): array public static function map(array $array, callable $callback): array + public static function mapWithKeys(array $array, callable $callback): array public static function each(array $array, callable $callback): array public static function reduce(array $array, callable $callback, mixed $initial = null): mixed public static function some(array $array, callable $callback): bool public static function every(array $array, callable $callback): bool public static function contains(array $array, mixed $valueOrCallback, bool $strict = false): bool + public static function countBy(array $array, ?callable $by = null): array public static function sum(array $array, ?callable $callback = null): float|int public static function unique(array $array, bool $strict = false): array + public static function values(array $array): array public static function reject(array $array, mixed $callback = true): array + public static function intersect(array $array, array $values, bool $strict = false): array + public static function diff(array $array, array $values, bool $strict = false): array + public static function symmetricDiff(array $left, array $right, bool $strict = false): array + public static function same(array $left, array $right, bool $strict = false): bool public static function slice(array $array, int $offset, ?int $length = null): array public static function skip(array $array, int $count): array public static function skipWhile(array $array, callable $callback): array @@ -186,7 +213,12 @@ ArraySingle public static function partition(array $array, callable $callback): array public static function mode(array $array): array public static function median(array $array): float|int + public static function min(array $array): float|int|null + public static function max(array $array): float|int|null + public static function minBy(array $array, callable $callback): mixed + public static function maxBy(array $array, callable $callback): mixed public static function except(array $array, array|string $keys): array + public static function rekey(array $array, array|callable $mapper): array ArrayMulti ---------------------------------- @@ -198,8 +230,11 @@ ArrayMulti public static function depth(array $array): int public static function flatten(array $array, float|int $depth = \INF): array public static function flattenByKey(array $array): array + public static function values(array $array): array + public static function rekey(array $array, array|callable $mapper): array public static function sortRecursive(array $array, int $options = \SORT_REGULAR, bool $descending = false): array public static function first(array $array, ?callable $callback = null, mixed $default = null): mixed + public static function firstWhere(array $array, string $key, mixed $operator = null, mixed $value = null, mixed $default = null): mixed public static function last(array $array, ?callable $callback = null, mixed $default = null): mixed public static function between(array $array, string $key, float|int $from, float|int $to): array public static function whereCallback(array $array, ?callable $callback = null, mixed $default = null): mixed @@ -218,15 +253,26 @@ ArrayMulti public static function skipWhile(array $array, callable $callback): array public static function skipUntil(array $array, callable $callback): array public static function sum(array $array, string|callable|null $keyOrCallback = null): float|int + public static function min(array $array, string|callable $keyOrCallback): float|int|null + public static function max(array $array, string|callable $keyOrCallback): float|int|null + public static function minBy(array $array, string|callable $keyOrCallback): mixed + public static function maxBy(array $array, string|callable $keyOrCallback): mixed + public static function countBy(array $array, string|callable $groupBy): array public static function whereIn(array $array, string $key, array $values, bool $strict = false): array public static function whereNotIn(array $array, string $key, array $values, bool $strict = false): array public static function whereNull(array $array, string $key): array public static function whereNotNull(array $array, string $key): array public static function groupBy(array $array, string|callable $groupBy, bool $preserveKeys = false): array + public static function keyBy(array $array, string|callable $keyBy): array + public static function indexBy(array $array, string|callable $indexBy): array + public static function mapWithKeys(array $array, callable $callback): array public static function sortBy(array $array, string|callable $by, bool $desc = false, int $options = \SORT_REGULAR): array public static function sortByDesc(array $array, string|callable $by, int $options = \SORT_REGULAR): array public static function transpose(array $matrix): array public static function pluck(array $array, string $column, ?string $indexBy = null): array + public static function mergeRecursiveDistinct(array $base, array $overrides): array + public static function replaceRecursive(array $base, array $replacements): array + public static function overlay(array $base, array $overlay): array DotNotation ----------------------------------- @@ -285,6 +331,8 @@ Collection uses ``BaseCollectionTrait``. Public API: public function keys(): array public function __debugInfo(): array public function clear(): void + public function copy(): static + public function immutable(): static public function merge(mixed $items): static public function offsetExists(mixed $offset): bool public function offsetGet(mixed $offset): mixed @@ -318,16 +366,23 @@ Pipeline public function __construct(protected array &$working, private readonly Collection $collection) public function only(array|string $keys): Collection + public function values(): Collection + public function rekey(array|callable $mapper): Collection public function nth(int $step, int $offset = 0): Collection public function duplicates(): Collection public function slice(int $offset, ?int $length = null): Collection public function paginate(int $page, int $perPage): Collection public function combine(array $values): Collection public function map(callable $callback): Collection + public function mapWithKeys(callable $callback): Collection public function filter(callable $callback): Collection public function chunk(int $size, bool $preserveKeys = false): Collection public function unique(bool $strict = false): Collection public function reject(mixed $callback = true): Collection + public function intersect(array $values, bool $strict = false): Collection + public function diff(array $values, bool $strict = false): Collection + public function symmetricDiff(array $values, bool $strict = false): Collection + public function same(array $values, bool $strict = false): bool public function skip(int $count): Collection public function skipWhile(callable $callback): Collection public function skipUntil(callable $callback): Collection @@ -337,7 +392,10 @@ Pipeline public function sortRecursive(int $options = SORT_REGULAR, bool $descending = false): Collection public function collapse(): Collection public function groupBy(string|callable $groupBy, bool $preserveKeys = false): Collection + public function keyBy(string|callable $keyBy): Collection + public function indexBy(string|callable $indexBy): Collection public function between(string $key, float|int $from, float|int $to): Collection + public function firstWhere(string $key, mixed $operator = null, mixed $value = null, mixed $default = null): mixed public function whereCallback(?callable $callback = null, mixed $default = null): Collection public function where(string $key, mixed $operator = null, mixed $value = null): Collection public function whereIn(string $key, array $values, bool $strict = false): Collection @@ -348,17 +406,26 @@ Pipeline public function isMultiDimensional(): bool public function wrap(): Collection public function unWrap(): Collection + public function unwrap(): Collection public function shuffle(?int $seed = null): Collection public function sum(?callable $callback = null): float|int + public function min(string|callable|null $keyOrCallback = null): float|int|null + public function max(string|callable|null $keyOrCallback = null): float|int|null public function first(?callable $callback = null, mixed $default = null): mixed public function last(?callable $callback = null, mixed $default = null): mixed public function reduce(callable $callback, mixed $initial = null): mixed public function any(callable $callback): bool + public function countBy(callable|string|null $groupBy = null): array public function except(array|string $keys): Collection public function median(): float|int public function mode(): array + public function minBy(string|callable $keyOrCallback): mixed + public function maxBy(string|callable $keyOrCallback): mixed public function pluck(string $column, ?string $indexBy = null): Collection public function transpose(): Collection + public function mergeRecursiveDistinct(array $overlay): Collection + public function replaceRecursive(array $replacements): Collection + public function overlay(array $overlay): Collection public function tap(callable $callback): Collection public function pipe(callable $callback): Collection public function when(bool $condition, callable $callback, ?callable $default = null): Collection @@ -377,11 +444,14 @@ Config uses ``BaseConfigTrait``. Public API: public function has(string|array $keys): bool public function hasAny(string|array $keys): bool public function get(string|int|array|null $key = null, mixed $default = null): mixed + public function getOrFail(string|int|array|null $key): mixed public function set(string|array|null $key = null, mixed $value = null, bool $overwrite = true): bool public function fill(string|array $key, mixed $value = null): bool public function forget(string|int|array $key): bool public function prepend(string $key, mixed $value): bool public function append(string $key, mixed $value): bool + public function replace(array $items): bool + public function reload(array|string $source): bool LazyFileConfig -------------------------------------- @@ -398,6 +468,7 @@ LazyFileConfig loads top-level config files on first keyed access: public function forget(string|int|array $key): bool public function preload(string|array $namespaces): static public function isLoaded(string $namespace): bool + public function loaded(string $namespace): bool public function loadedNamespaces(): array public function all(): array // throws (design choice) diff --git a/docs/traits-and-helpers.rst b/docs/traits-and-helpers.rst index 968c144..cbc0c7c 100644 --- a/docs/traits-and-helpers.rst +++ b/docs/traits-and-helpers.rst @@ -146,6 +146,14 @@ Available helpers: - ``collect(mixed $data = []): Collection`` - ``chain(mixed $data): Pipeline`` +Namespaced alternatives are also available to avoid global symbol collisions: + +- ``Infocyph\ArrayKit\compare()`` +- ``Infocyph\ArrayKit\array_get()`` +- ``Infocyph\ArrayKit\array_set()`` +- ``Infocyph\ArrayKit\collect()`` +- ``Infocyph\ArrayKit\chain()`` + array_get / array_set ~~~~~~~~~~~~~~~~~~~~~ diff --git a/pest.xml b/pest.xml deleted file mode 100644 index d5d12d8..0000000 --- a/pest.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - ./tests - - - - - ./src - - - - - - - diff --git a/phpbench.json b/phpbench.json deleted file mode 100644 index fff7e05..0000000 --- a/phpbench.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "$schema": "./vendor/phpbench/phpbench/phpbench.schema.json", - "runner.bootstrap": "vendor/autoload.php", - "runner.path": "benchmarks", - "runner.file_pattern": "*Bench.php", - "runner.attributes": true, - "runner.annotations": false, - "runner.progress": "dots", - "runner.retry_threshold": 8, - "report.generators": { - "chart": { - "title": "Benchmark Chart", - "description": "Console bar chart grouped by benchmark subject", - "generator": "component", - "components": [ - { - "component": "bar_chart_aggregate", - "x_partition": ["subject_name"], - "bar_partition": ["benchmark_name"], - "y_expr": "mode(partition['result_time_avg'])", - "y_axes_label": "yValue as time precision 1" - } - ] - } - } -} diff --git a/phpcs.xml.dist b/phpcs.xml.dist deleted file mode 100644 index 666c061..0000000 --- a/phpcs.xml.dist +++ /dev/null @@ -1,52 +0,0 @@ - - - Semantic PHPCS checks not covered by Pint/Psalm/PHPStan. - - - - - - - ./src - ./tests - - */vendor/* - */.git/* - */.idea/* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/phpstan.neon.dist b/phpstan.neon.dist deleted file mode 100644 index 0adc1df..0000000 --- a/phpstan.neon.dist +++ /dev/null @@ -1,14 +0,0 @@ -includes: - - vendor/tomasvotruba/cognitive-complexity/config/extension.neon - -parameters: - customRulesetUsed: true - paths: - - src - parallel: - maximumNumberOfProcesses: 1 - cognitive_complexity: - class: 150 - function: 14 - dependency_tree: 150 - dependency_tree_types: [] diff --git a/phpunit.xml b/phpunit.xml deleted file mode 100644 index d5d12d8..0000000 --- a/phpunit.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - ./tests - - - - - ./src - - - - - - - diff --git a/pint.json b/pint.json deleted file mode 100644 index 46529c3..0000000 --- a/pint.json +++ /dev/null @@ -1,73 +0,0 @@ -{ - "preset": "per", - "exclude": [ - "tests" - ], - "notPath": [ - "rector.php" - ], - "rules": { - "ordered_imports": { - "imports_order": ["class", "function", "const"], - "sort_algorithm": "alpha" - }, - "no_unused_imports": true, - - "ordered_class_elements": { - "order": [ - "use_trait", - - "case", - - "constant_public", - "constant_protected", - "constant_private", - "constant", - - "property_public_static", - "property_protected_static", - "property_private_static", - "property_static", - - "property_public_readonly", - "property_protected_readonly", - "property_private_readonly", - - "property_public_abstract", - "property_protected_abstract", - - "property_public", - "property_protected", - "property_private", - "property", - - "construct", - "destruct", - "magic", - "phpunit", - - "method_public_abstract_static", - "method_protected_abstract_static", - "method_private_abstract_static", - - "method_public_abstract", - "method_protected_abstract", - "method_private_abstract", - "method_abstract", - - "method_public_static", - "method_public", - - "method_protected_static", - "method_protected", - - "method_private_static", - "method_private", - - "method_static", - "method" - ], - "sort_algorithm": "alpha" - } - } -} diff --git a/psalm.xml b/psalm.xml deleted file mode 100644 index 49a4a35..0000000 --- a/psalm.xml +++ /dev/null @@ -1,40 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/rector.php b/rector.php deleted file mode 100644 index e30ddf8..0000000 --- a/rector.php +++ /dev/null @@ -1,14 +0,0 @@ -withPaths([__DIR__ . '/src']) - ->withPreparedSets(deadCode: true) - ->withPhpVersion( - constant(PhpVersion::class . '::PHP_' . PHP_MAJOR_VERSION . PHP_MINOR_VERSION), - ) - ->withPhpSets(); diff --git a/src/Array/ArrayMulti.php b/src/Array/ArrayMulti.php index 2523cda..1ed4b89 100644 --- a/src/Array/ArrayMulti.php +++ b/src/Array/ArrayMulti.php @@ -4,58 +4,28 @@ namespace Infocyph\ArrayKit\Array; -use RecursiveArrayIterator; -use RecursiveIteratorIterator; +use Infocyph\ArrayKit\Array\Concerns\ArrayMultiQuerySortTrait; class ArrayMulti { - /** - * Filter a 2D array by a single key's comparison (like "where 'age' between 18 and 65"). - * - * @param array $array The 2D array to filter. - * @param string $key The key in each sub-array to compare. - * @param float|int $from The lower bound of the comparison. - * @param float|int $to The upper bound of the comparison. - * @return array The filtered array. - */ - public static function between(array $array, string $key, float|int $from, float|int $to): array - { - return array_filter($array, fn($item) => ArraySingle::exists($item, $key) - && compare($item[$key], $from, '>=') - && compare($item[$key], $to, '<=')); - } + use ArrayMultiQuerySortTrait; /** - * Break a 2D array into smaller chunks of a specified size. - * - * This function splits the input array into multiple smaller arrays, each - * containing up to the specified number of elements. If the specified size - * is less than or equal to zero, the entire array is returned as a single chunk. - * - * @param array $array The array to be chunked. - * @param int $size The size of each chunk. - * @param bool $preserveKeys Whether to preserve the keys in the chunks. - * - * @return array An array of arrays, each representing a chunk of the original array. + * @param array $array + * @return array */ public static function chunk(array $array, int $size, bool $preserveKeys = false): array { if ($size <= 0) { return [$array]; } + return array_chunk($array, $size, $preserveKeys); } /** - * Collapses a multidimensional array into a single-dimensional array. - * - * This method takes a multidimensional array and merges all its - * sub-arrays into a single-level array. Only one level of the - * array is collapsed, so nested arrays within sub-arrays will - * remain unchanged. - * - * @param array $array The multidimensional array to collapse. - * @return array A single-dimensional array with all sub-array elements. + * @param array $array + * @return array */ public static function collapse(array $array): array { @@ -65,44 +35,24 @@ public static function collapse(array $array): array array_push($results, ...$values); } } + return $results; } /** - * Determine if the array contains a given value or if a callback function - * returns true for at least one element. - * - * If the second argument is a callable, it is used as a callback function - * that receives the value and key of each element in the array. If the - * callback returns true, the function returns true. - * - * If the second argument is not a callable, it is used as the value to - * search for in the array. The optional third argument determines whether - * to use strict comparison (===) or loose comparison (==). - * - * @param array $array The array to search. - * @param mixed $valueOrCallback The value to search for, or a callable to apply to each element. - * @param bool $strict Whether to use strict comparison (===) or loose comparison (==). - * @return bool Whether the array contains the given value or whether the callback returned true for at least one element. + * @param array $array */ public static function contains(array $array, mixed $valueOrCallback, bool $strict = false): bool { if (is_callable($valueOrCallback)) { return static::some($array, $valueOrCallback); } + return in_array($valueOrCallback, $array, $strict); } /** - * Determine the depth of a multidimensional array. - * - * The depth is the level of nesting of the array, i.e. the - * number of levels of arrays that are nested within one - * another. The outermost level is 1, and each nested level - * increments the depth by 1. - * - * @param array $array The multidimensional array to determine the depth of. - * @return int The depth of the array. + * @param array $array */ public static function depth(array $array): int { @@ -110,62 +60,28 @@ public static function depth(array $array): int return 0; } - $depth = 0; - $iterator = new RecursiveIteratorIterator(new RecursiveArrayIterator($array)); - - foreach ($iterator as $unused) { - $depth = max($depth, $iterator->getDepth()); - } - return $depth + 1; // zero-based => plus one + return self::measureDepth($array); } /** - * Execute a callback on each item in the array, returning the original array. - * - * The callback function receives two arguments: the value of the current - * element and its key. The callback should return a value that can be - * evaluated to boolean. If the callback returns false, the iteration is - * broken. Otherwise, the iteration continues. - * - * @param array $array The array to be iterated over. - * @param callable $callback The callback function to apply to each element. - * - * @return array The original array. + * @param array $array + * @return array */ public static function each(array $array, callable $callback): array { - foreach ($array as $key => $row) { - if ($callback($row, $key) === false) { - break; - } - } - return $array; + return ArraySharedOps::each($array, $callback); } /** - * Determine if all rows in a 2D array pass the given truth test. - * - * The callback function receives two arguments: the value of the current - * row and its key. It should return true if the condition is met, or false otherwise. - * - * @param array $array The array of rows to evaluate. - * @param callable $callback The callback to apply to each row. - * @return bool Whether all rows passed the truth test. + * @param array $array */ public static function every(array $array, callable $callback): bool { - return array_all($array, fn($row, $key) => $callback($row, $key)); + return ArraySharedOps::every($array, $callback); } /** - * Return the first item in a 2D array, or single-dim array, depending on usage. - * If a callback is provided, return the first item that matches the callback. - * Otherwise, return the first item in the array. - * - * @param array $array The array to search in. - * @param callable|null $callback The callback to apply to each element. - * @param mixed $default The default value to return if the array is empty. - * @return mixed The first item in the array, or the default value if empty. + * @param array $array */ public static function first(array $array, ?callable $callback = null, mixed $default = null): mixed { @@ -178,19 +94,13 @@ public static function first(array $array, ?callable $callback = null, mixed $de return $value; } } + return $default; } /** - * Recursively flatten a multidimensional array to a specified depth. - * - * This method takes a multidimensional array and reduces it to a single-level - * array up to the specified depth. If no depth is specified or if it is set - * to \INF, the array will be completely flattened. - * - * @param array $array The multidimensional array to flatten. - * @param float|int $depth The maximum depth to flatten. Defaults to infinite. - * @return array A flattened array up to the specified depth. + * @param array $array + * @return array */ public static function flatten(array $array, float|int $depth = \INF): array { @@ -208,185 +118,104 @@ public static function flatten(array $array, float|int $depth = \INF): array } } } + return $result; } /** - * Flatten the array into a single level but preserve keys. - * - * This method takes a multidimensional array and reduces it to a single-level - * array, but preserves all keys. The resulting array will have the same keys - * as the original array, but with all nested arrays flattened to the same - * level. - * - * @param array $array The multidimensional array to flatten. - * @return array A flattened array with all nested arrays flattened to the same level. + * @param array $array + * @return array */ public static function flattenByKey(array $array): array { - return iterator_to_array( - new RecursiveIteratorIterator(new RecursiveArrayIterator($array)), - false, - ); + $results = []; + self::flattenByKeyInto($array, $results); + + return $results; } /** - * Group a 2D array by a given column or callback. - * - * This method takes a 2D array and a key or a callback as parameters. - * It returns a new array containing the grouped data. - * - * If the grouping key is a string, it is used as a key in each sub-array to group by. - * If the grouping key is a callable, it is called with each sub-array and its key as arguments, - * and the return value is used as the grouping key. - * - * If the `$preserveKeys` parameter is true, the original key from the array is preserved - * in the grouped array. Otherwise, the grouped array values are indexed numerically. - * - * @param array $array The array to group. - * @param string|callable $groupBy The key or callback to group by. - * @param bool $preserveKeys Whether to preserve the original key in the grouped array. - * @return array The grouped array. + * @param array $array + * @return array */ - public static function groupBy(array $array, string|callable $groupBy, bool $preserveKeys = false): array + public static function map(array $array, callable $callback): array { $results = []; foreach ($array as $key => $row) { - $gKey = null; - if (is_callable($groupBy)) { - $gKey = $groupBy($row, $key); - } elseif (isset($row[$groupBy])) { - $gKey = $row[$groupBy]; - } else { - $gKey = '_undefined'; - } - - if ($preserveKeys) { - $results[$gKey][$key] = $row; - } else { - $results[$gKey][] = $row; - } + $results[$key] = $callback($row, $key); } + return $results; } /** - * Return the last item in a 2D array or single-dim array, depending on usage. - * If a callback is provided, return the last item that matches the callback. - * Otherwise, return the last item in the array. + * Recursively merge arrays without converting scalar collisions into arrays. * - * @param array $array The array to search in. - * @param callable|null $callback The callback to apply to each element. - * @param mixed $default The default value to return if the array is empty. - * @return mixed The last item in the array, or the default value if empty. + * @param array $base + * @param array $overrides + * @return array */ - public static function last(array $array, ?callable $callback = null, mixed $default = null): mixed + public static function mergeRecursiveDistinct(array $base, array $overrides): array { - if ($callback === null) { - return empty($array) ? $default : end($array); + foreach ($overrides as $key => $value) { + if ( + array_key_exists($key, $base) + && is_array($base[$key]) + && is_array($value) + ) { + $base[$key] = self::mergeRecursiveDistinct($base[$key], $value); + } else { + $base[$key] = $value; + } } - // Reverse array with preserve_keys = true: - return static::first(array_reverse($array, true), $callback, $default); - } - /** - * Apply a callback to each row in the array, optionally preserving keys. - * - * The callback function receives two arguments: the value of the current - * element and its key. The callback should return the value to be used - * in the resulting array. - * - * @param array $array The array to be mapped over. - * @param callable $callback The callback function to apply to each element. - * - * @return array The array with each element transformed by the callback. - */ - public static function map(array $array, callable $callback): array - { - $results = []; - foreach ($array as $key => $row) { - $results[$key] = $callback($row, $key); - } - return $results; + return $base; } + /** - * Select only certain keys from a multidimensional array. - * - * This method is the multidimensional equivalent of ArraySingle::only. - * - * @param array $array the multidimensional array to select from - * @param array|string $keys the keys to select - * @return array a new array with the selected keys + * @param array $array + * @param array|string $keys + * @return array */ public static function only(array $array, array|string $keys): array { $result = []; - $pick = array_flip((array) $keys); + /** @var array $pickKeys */ + $pickKeys = (array) $keys; + $pick = array_flip($pickKeys); foreach ($array as $item) { if (is_array($item)) { $result[] = array_intersect_key($item, $pick); } } + return $result; } /** - * Partition the array into two arrays [passed, failed] based on a callback. - * - * The method takes an array and a callback as parameters. - * It iterates over the array, applying the callback to each item. - * If the callback returns true, the item is added to the "passed" array. - * If the callback returns false, the item is added to the "failed" array. - * The method returns an array with two elements, the first being the "passed" array, - * and the second being the "failed" array. + * Overlay one array on top of another (distinct recursive merge). * - * @param array $array The array to partition. - * @param callable $callback The callback to use for partitioning. - * @return array An array with two elements, the first being the "passed" array, and the second being the "failed" array. + * @param array $base + * @param array $overlay + * @return array */ - public static function partition(array $array, callable $callback): array + public static function overlay(array $base, array $overlay): array { - $passed = []; - $failed = []; - foreach ($array as $key => $row) { - if ($callback($row, $key)) { - $passed[$key] = $row; - } else { - $failed[$key] = $row; - } - } - return [$passed, $failed]; + return self::mergeRecursiveDistinct($base, $overlay); } - public static function pluck(array $array, string $column, ?string $indexBy = null): array + /** + * @param array $array + * @return array + */ + public static function partition(array $array, callable $callback): array { - $results = []; - foreach ($array as $row) { - if (!is_array($row) || !array_key_exists($column, $row)) { - continue; - } - $value = $row[$column]; - if ($indexBy !== null && array_key_exists($indexBy, $row)) { - $results[$row[$indexBy]] = $value; - } else { - $results[] = $value; - } - } - return $results; + return ArraySharedOps::partition($array, $callback); } /** - * Reduce an array to a single value using a callback function. - * - * The callback function receives three arguments: the accumulator, - * the current array value, and the current array key. It should return - * the updated accumulator value. - * - * @param array $array The array to reduce. - * @param callable $callback The callback function to apply to each element. - * @param mixed $initial The initial value of the accumulator. - * @return mixed The reduced value. + * @param array $array */ public static function reduce(array $array, callable $callback, mixed $initial = null): mixed { @@ -394,414 +223,151 @@ public static function reduce(array $array, callable $callback, mixed $initial = foreach ($array as $key => $row) { $accumulator = $callback($accumulator, $row, $key); } - return $accumulator; - } - - /** - * Return an array with all values that do not pass the given callback. - * - * The method takes an array and an optional callback as parameters. - * If the callback is not provided, it defaults to `true`, which means the method will return an array with all - * values that are not equal to `true`. - * If the callback is a callable, the method will use it to filter the array. If the callback returns `false` for - * a value, that value will be rejected. - * If the callback is not a callable, the method will use it as the value to compare against. If the value is equal - * to the callback, it will be rejected. - * - * The method returns an array with the same type of indices as the input array. - * - * @param array $array The array to filter. - * @param mixed $callback The callback to use for filtering, or the value to compare against. Defaults to `true`. - * @return array The filtered array. - */ - public static function reject(array $array, mixed $callback = true): array - { - // Could unify via BaseArrayHelper::doReject($array, $callback). - // Or keep local logic: - if (is_callable($callback)) { - return array_filter($array, fn($row, $key) => !$callback($row, $key), \ARRAY_FILTER_USE_BOTH); - } - return array_filter($array, fn($row) => $row != $callback); - } - /** - * Skip the first $count items of the array and return the remainder. - * - * The method takes two parameters: the array to skip and the number of items to skip. - * It returns an array with the same type of indices as the input array. - * - * @param array $array The array to skip. - * @param int $count The number of items to skip. - * @return array The skipped array. - */ - public static function skip(array $array, int $count): array - { - return array_slice($array, $count, null, true); - } - - /** - * Skip rows until the callback returns true, then keep the remainder. - * - * The method takes an array and a callback as parameters. - * It iterates over the array, applying the callback to each row. - * As long as the callback returns false, the row is skipped. - * The first row for which the callback returns true is kept, - * and all subsequent rows are also kept. - * The method returns an array with the same type of indices as the input array. - * - * @param array $array The array to skip. - * @param callable $callback The callback to use for skipping. - * @return array The skipped array. - */ - public static function skipUntil(array $array, callable $callback): array - { - return static::skipWhile($array, fn($row, $key) => !$callback($row, $key)); + return $accumulator; } /** - * Skip rows while the callback returns true; once false, keep the remainder. - * - * The method takes an array and a callback as parameters. - * It iterates over the array, applying the callback to each row. - * As long as the callback returns true, the row is skipped. - * The first row for which the callback returns false is kept, - * and all subsequent rows are also kept. - * The method returns an array with the same type of indices as the input array. + * Rename top-level keys using a map or callback. * - * @param array $array The array to skip. - * @param callable $callback The callback to use for skipping. - * @return array The skipped array. + * @param array $array + * @param array|callable $mapper + * @return array */ - public static function skipWhile(array $array, callable $callback): array + public static function rekey(array $array, array|callable $mapper): array { - $result = []; - $skipping = true; - foreach ($array as $key => $row) { - if ($skipping && !$callback($row, $key)) { - $skipping = false; - } - if (!$skipping) { - $result[$key] = $row; - } - } - return $result; + return ArraySingle::rekey($array, $mapper); } /** - * Check if the array (of rows) contains at least one row matching a condition + * Recursively replace values (wrapper for array_replace_recursive). * - * @param array $array The array to search. - * @param callable $callback The callback to apply to each element. - * @return bool Whether at least one element passed the truth test. + * @param array $base + * @param array $replacements + * @return array */ - public static function some(array $array, callable $callback): bool + public static function replaceRecursive(array $base, array $replacements): array { - return array_any($array, fn($row, $key) => $callback($row, $key)); + return array_replace_recursive($base, $replacements); } /** - * Sort a 2D array by a specified column or using a callback function. - * - * This method sorts an array based on a given column name or a custom callback. - * The sorting can be performed in ascending or descending order, and it allows - * specifying sorting options. - * - * @param array $array The array to sort. - * @param string|callable $by The column key to sort by, or a callable function that returns the value to sort by. - * @param bool $desc Whether to sort in descending order. Defaults to false (ascending order). - * @param int $options The sorting options. Defaults to SORT_REGULAR. - * @return array The sorted array. + * @param array $array + * @return array */ - public static function sortBy( - array $array, - string|callable $by, - bool $desc = false, - int $options = \SORT_REGULAR, - ): array { - uasort($array, function ($a, $b) use ($by, $desc, $options) { - $valA = is_callable($by) ? $by($a) : ($a[$by] ?? null); - $valB = is_callable($by) ? $by($b) : ($b[$by] ?? null); - - $comparison = static::compareSortValues($valA, $valB, $options); - - return $desc ? -$comparison : $comparison; - }); - return $array; - } - - /** - * Sort a 2D array by a specified column or using a callback function, in descending order. - * - * This is a convenience method for calling `sortBy` with the third argument set to true. - * - * @param array $array The array to sort. - * @param string|callable $by The column key to sort by, or a callable function that returns the value to sort by. - * @param int $options The sorting options. Defaults to SORT_REGULAR. - * @return array The sorted array. - */ - public static function sortByDesc(array $array, string|callable $by, int $options = \SORT_REGULAR): array + public static function skip(array $array, int $count): array { - return static::sortBy($array, $by, true, $options); + return ArraySharedOps::skip($array, $count); } /** - * Recursively sort a multidimensional array by keys/values. - * - * This method takes a multidimensional array and recursively sorts it by - * keys or values. The sorting options and direction are determined by the - * $options and $descending parameters respectively. - * - * @param array $array The multidimensional array to sort. - * @param int $options The sorting options. Defaults to SORT_REGULAR. - * @param bool $descending Whether to sort in descending order. Defaults to false. - * @return array The sorted array. + * @param array $array + * @return array */ - public static function sortRecursive(array $array, int $options = \SORT_REGULAR, bool $descending = false): array + public static function skipUntil(array $array, callable $callback): array { - foreach ($array as &$value) { - if (is_array($value)) { - $value = static::sortRecursive($value, $options, $descending); - } - } - - if (ArraySingle::isAssoc($array)) { - $descending - ? krsort($array, $options) - : ksort($array, $options); - } else { - $descending - ? rsort($array, $options) - : sort($array, $options); - } - return $array; + return ArraySharedOps::skipUntil($array, $callback); } /** - * Calculate the sum of an array of values, optionally using a key or callback to extract the values to sum. - * - * If no key or callback is provided, the method will add up all numeric values in the array. - * If a key is provided, the method will add up all values in the array that are keyed by that column. - * If a callback is provided, the method will pass each row in the array to the callback and add up the results. - * - * @param array $array The array to sum. - * @param string|callable|null $keyOrCallback The key or callback to use to extract the values to sum. - * @return float|int The sum of the values in the array. + * @param array $array + * @return array */ - public static function sum(array $array, string|callable|null $keyOrCallback = null): float|int + public static function skipWhile(array $array, callable $callback): array { - $total = 0; - foreach ($array as $row) { - if ($keyOrCallback === null) { - if (is_numeric($row)) { - $total += $row; - } - } elseif (is_callable($keyOrCallback)) { - $total += $keyOrCallback($row); - } else { - if (isset($row[$keyOrCallback]) && is_numeric($row[$keyOrCallback])) { - $total += $row[$keyOrCallback]; - } - } - } - return $total; + return ArraySharedOps::skipWhile($array, $callback); } /** - * Transpose a 2D array (matrix). - * - * This method takes a matrix (2D array) and returns a new matrix where the - * rows are converted into columns and vice versa. If the input matrix is empty, - * it returns an empty array. - * - * @param array $matrix The matrix to transpose. - * @return array The transposed matrix. + * @param array $matrix + * @return array */ public static function transpose(array $matrix): array { if (empty($matrix)) { return []; } - $keys = array_keys(current($matrix)); + $firstRow = current($matrix); + if (!is_array($firstRow)) { + return []; + } + + $keys = array_keys($firstRow); $results = array_fill_keys($keys, []); foreach ($matrix as $row) { + if (!is_array($row)) { + continue; + } + foreach ($row as $col => $value) { $results[$col][] = $value; } } + return $results; } /** - * Return a new array with all duplicate rows removed. - * - * The method takes an array and an optional boolean parameter as arguments. - * If the boolean parameter is not provided, it defaults to false, which means - * loose comparison (==) will be used when checking for duplicate values. - * If the boolean parameter is true, strict comparison (===) will be used. - * - * The method iterates over the array, keeping track of values seen so far - * in an array. If a value is seen for the first time, it is added to the - * results array. If a value is seen again, it is skipped. - * If the value is an array itself, it is serialized before being compared. - * - * @param array $array The array to remove duplicates from. - * @param bool $strict Whether to use strict comparison (===) or loose comparison (==). Defaults to false. - * @return array The array with all duplicate values removed. + * @param array $array + * @return array */ public static function unique(array $array, bool $strict = false): array { $seen = []; $results = []; foreach ($array as $key => $row) { - // If the row is itself an array, we serialize it for comparison: $compareValue = is_array($row) ? serialize($row) : $row; if (!in_array($compareValue, $seen, $strict)) { $seen[] = $compareValue; $results[$key] = $row; } } - return $results; - } - /** - * Filter a 2D array by a single key's comparison (like "where 'age' > 18"). - * - * If the third argument is omitted, the second argument is treated as the value to compare. - * If the third argument is provided, it is used as the operator for the comparison. - * - * @param array $array The 2D array to filter. - * @param string $key The key in each sub-array to compare. - * @param mixed $operator The operator to use for the comparison. If null, the second argument is treated as the value to compare. - * @param mixed $value The value to compare. - * @return array The filtered array. - */ - public static function where(array $array, string $key, mixed $operator = null, mixed $value = null): array - { - // If only 2 args, treat second as $value - if ($value === null && $operator !== null) { - $value = $operator; - $operator = null; - } - - return array_filter($array, fn($item) => ArraySingle::exists($item, $key) && compare($item[$key], $value, $operator)); + return $results; } /** - * Filter a 2D array by a custom callback function on each row. - * - * If no callback is provided, the method will return the entire array. - * If the array is empty and a default value is provided, that value will be returned. + * Reindex the top-level array numerically from zero. * - * @param array $array The 2D array to filter. - * @param callable|null $callback The callback function to apply to each element. - * If null, the method will return the entire array. - * @param mixed $default The default value to return if the array is empty. - * @return mixed The filtered array, or the default value if the array is empty. + * @param array $array + * @return array */ - public static function whereCallback(array $array, ?callable $callback = null, mixed $default = null): mixed + public static function values(array $array): array { - if ($callback === null) { - return empty($array) ? $default : $array; - } - return array_filter($array, fn($item, $index) => $callback($item, $index), \ARRAY_FILTER_USE_BOTH); + return array_values($array); } /** - * Filter rows where "column" matches one of the given values. - * - * @param array $array The array to filter. - * @param string $key The key in each sub-array to compare. - * @param array $values The values to search for. - * @param bool $strict Whether to use strict comparison (===) or loose comparison (==). - * @return array The filtered array. + * @param array $array + * @param array $results */ - public static function whereIn(array $array, string $key, array $values, bool $strict = false): array + private static function flattenByKeyInto(array $array, array &$results): void { - return array_filter( - $array, - fn($row) - => isset($row[$key]) && in_array($row[$key], $values, $strict), - ); - } + foreach ($array as $value) { + if (is_array($value)) { + self::flattenByKeyInto($value, $results); - /** - * Filter rows where "column" does NOT match one of the given values. - * - * @param array $array The array to filter. - * @param string $key The key in each sub-array to compare. - * @param array $values The values to search for. - * @param bool $strict Whether to use strict comparison (===) or loose comparison (==). - * @return array The filtered array. - */ - public static function whereNotIn(array $array, string $key, array $values, bool $strict = false): array - { - return array_filter( - $array, - fn($row) - => !isset($row[$key]) || !in_array($row[$key], $values, $strict), - ); - } + continue; + } - /** - * Filter rows where a column is not null. - * - * This method takes a 2D array and a key as parameters. It returns a new array - * containing only the rows where the specified key exists and its value is not null. - * - * @param array $array The array to filter. - * @param string $key The key in each sub-array to check for non-null value. - * @return array The filtered array with rows where the specified key is not null. - */ - public static function whereNotNull(array $array, string $key): array - { - return array_filter($array, fn($row) => isset($row[$key])); + $results[] = $value; + } } /** - * Filter rows where a column is null. - * - * This method takes a 2D array and a key as parameters. It returns a new array - * containing only the rows where the specified key exists and its value is null. - * - * @param array $array The array to filter. - * @param string $key The key in each sub-array to check for null value. - * @return array The filtered array with rows where the specified key is null. + * @param array $array */ - public static function whereNull(array $array, string $key): array + private static function measureDepth(array $array): int { - return array_filter( - $array, - fn($row) - => !empty($row) && array_key_exists($key, $row) && $row[$key] === null, - ); - } + $maxDepth = 1; - /** - * Compare two values according to PHP sort options. - * - * Supports SORT_REGULAR, SORT_NUMERIC, SORT_STRING, SORT_NATURAL, - * SORT_LOCALE_STRING, and SORT_FLAG_CASE (for string/natural sorts). - */ - private static function compareSortValues(mixed $left, mixed $right, int $options): int - { - if ($left === $right) { - return 0; + foreach ($array as $value) { + if (is_array($value) && $value !== []) { + $maxDepth = max($maxDepth, self::measureDepth($value) + 1); + } } - $caseInsensitive = (bool) ($options & \SORT_FLAG_CASE); - $baseOption = $options & ~\SORT_FLAG_CASE; - - return match ($baseOption) { - \SORT_NUMERIC => (float) $left <=> (float) $right, - \SORT_STRING => $caseInsensitive - ? strcasecmp((string) $left, (string) $right) - : strcmp((string) $left, (string) $right), - \SORT_NATURAL => $caseInsensitive - ? strnatcasecmp((string) $left, (string) $right) - : strnatcmp((string) $left, (string) $right), - \SORT_LOCALE_STRING => strcoll((string) $left, (string) $right), - default => $left <=> $right, - }; + return $maxDepth; } } diff --git a/src/Array/ArraySharedOps.php b/src/Array/ArraySharedOps.php new file mode 100644 index 0000000..1a28d7b --- /dev/null +++ b/src/Array/ArraySharedOps.php @@ -0,0 +1,124 @@ + $array + * @return array + */ + public static function each(array $array, callable $callback): array + { + foreach ($array as $key => $value) { + if ($callback($value, $key) === false) { + break; + } + } + + return $array; + } + + /** + * @param array $array + */ + public static function every(array $array, callable $callback): bool + { + return array_all($array, static fn(mixed $value, int|string $key): bool => (bool) $callback($value, $key)); + } + + public static function normalizeArrayKey(mixed $value): int|string + { + if (is_int($value) || is_string($value)) { + return $value; + } + + return self::asString($value); + } + + /** + * @param array $array + * @return array + */ + public static function partition(array $array, callable $callback): array + { + $passed = []; + $failed = []; + + foreach ($array as $key => $value) { + if ($callback($value, $key)) { + $passed[$key] = $value; + } else { + $failed[$key] = $value; + } + } + + return [$passed, $failed]; + } + + /** + * @param array $array + * @return array + */ + public static function skip(array $array, int $count): array + { + return array_slice($array, $count, null, true); + } + + /** + * @param array $array + * @return array + */ + public static function skipUntil(array $array, callable $callback): array + { + return self::skipWhile($array, fn($value, $key) => !$callback($value, $key)); + } + + /** + * @param array $array + * @return array + */ + public static function skipWhile(array $array, callable $callback): array + { + $result = []; + $skipping = true; + + foreach ($array as $key => $value) { + if ($skipping && !$callback($value, $key)) { + $skipping = false; + } + if (!$skipping) { + $result[$key] = $value; + } + } + + return $result; + } +} diff --git a/src/Array/ArraySingle.php b/src/Array/ArraySingle.php index b8b3121..62d3b36 100644 --- a/src/Array/ArraySingle.php +++ b/src/Array/ArraySingle.php @@ -11,7 +11,7 @@ class ArraySingle /** * Calculate the average of an array of numbers. * - * @param array $array The array of numbers to average. + * @param array $array The array of numbers to average. * @return float|int The average of the numbers in the array. If the array is empty, 0 is returned. */ public static function avg(array $array): float|int @@ -19,6 +19,7 @@ public static function avg(array $array): float|int if (empty($array)) { return 0; } + return array_sum($array) / count($array); } @@ -29,17 +30,18 @@ public static function avg(array $array): float|int * containing up to the specified number of elements. If the specified size * is less than or equal to zero, the entire array is returned as a single chunk. * - * @param array $array The array to be chunked. + * @param array $array The array to be chunked. * @param int $size The size of each chunk. * @param bool $preserveKeys Whether to preserve the keys in the chunks. * - * @return array An array of arrays, each representing a chunk of the original array. + * @return array An array of arrays, each representing a chunk of the original array. */ public static function chunk(array $array, int $size, bool $preserveKeys = false): array { if ($size <= 0) { return [$array]; } + return array_chunk($array, $size, $preserveKeys); } @@ -50,10 +52,10 @@ public static function chunk(array $array, int $size, bool $preserveKeys = false * into a single array. If the two arrays are not of equal length, the function * will truncate the longer array to match the length of the shorter array. * - * @param array $keys The array of keys. - * @param array $values The array of values. + * @param array $keys The array of keys. + * @param array $values The array of values. * - * @return array The combined array. + * @return array The combined array. */ public static function combine(array $keys, array $values): array { @@ -66,7 +68,12 @@ public static function combine(array $keys, array $values): array $values = array_slice($values, 0, $size); } - return array_combine($keys, $values); + $normalizedKeys = array_map( + self::normalizeArrayKey(...), + array_values($keys), + ); + + return array_combine($normalizedKeys, array_values($values)); } /** @@ -81,7 +88,7 @@ public static function combine(array $keys, array $values): array * search for in the array. The optional third argument determines whether * to use strict comparison (===) or loose comparison (==). * - * @param array $array The array to search. + * @param array $array The array to search. * @param mixed $valueOrCallback The value to search for, or a callable to apply to each element. * @param bool $strict Whether to use strict comparison (===) or loose comparison (==). * @return bool Whether the array contains the given value or whether the callback returned true for at least one element. @@ -91,33 +98,65 @@ public static function contains(array $array, mixed $valueOrCallback, bool $stri if (is_callable($valueOrCallback)) { return static::some($array, $valueOrCallback); } + return in_array($valueOrCallback, $array, $strict); } /** * Determine if all given values exist in the array. * - * @param array $array The array to search. - * @param array $needles The values to verify. + * @param array $array The array to search. + * @param array $needles The values to verify. * @param bool $strict Whether to use strict comparison. * @return bool True if every value exists, false otherwise. */ public static function containsAll(array $array, array $needles, bool $strict = false): bool { - return array_all($needles, fn($needle) => in_array($needle, $array, $strict)); + return ArraySingleOps::containsAll($array, $needles, $strict); } /** * Determine if any of the given values exist in the array. * - * @param array $array The array to search. - * @param array $needles The values to verify. + * @param array $array The array to search. + * @param array $needles The values to verify. * @param bool $strict Whether to use strict comparison. * @return bool True if at least one value exists, false otherwise. */ public static function containsAny(array $array, array $needles, bool $strict = false): bool { - return array_any($needles, fn($needle) => in_array($needle, $array, $strict)); + return ArraySingleOps::containsAny($array, $needles, $strict); + } + + /** + * Count values grouped by value or callback output. + * + * @param array $array + * @return array + */ + public static function countBy(array $array, ?callable $by = null): array + { + $counts = []; + + foreach ($array as $key => $value) { + $bucket = $by ? $by($value, $key) : $value; + $normalized = self::normalizeArrayKey($bucket); + $counts[$normalized] = ($counts[$normalized] ?? 0) + 1; + } + + return $counts; + } + + /** + * Return values from the first array not present in the second array. + * + * @param array $array + * @param array $values + * @return array + */ + public static function diff(array $array, array $values, bool $strict = false): array + { + return ArraySingleOps::diff($array, $values, $strict); } /** @@ -125,18 +164,12 @@ public static function containsAny(array $array, array $needles, bool $strict = * * This method returns an array of values that occur more than once in the input array. * - * @param array $array The array to search for duplicates. - * @return array An array of duplicate values. + * @param array $array The array to search for duplicates. + * @return array An array of duplicate values. */ public static function duplicates(array $array): array { - $duplicates = []; - foreach (array_count_values($array) as $value => $count) { - if ($count > 1) { - $duplicates[] = $value; - } - } - return $duplicates; + return ArraySingleOps::duplicates($array); } /** @@ -147,44 +180,43 @@ public static function duplicates(array $array): array * evaluated to boolean. If the callback returns false, the iteration is * broken. Otherwise, the iteration continues. * - * @param array $array The array to be iterated over. + * @param array $array The array to be iterated over. * @param callable $callback The callback function to apply to each element. * - * @return array The original array. + * @return array The original array. */ public static function each(array $array, callable $callback): array { - foreach ($array as $key => $value) { - if ($callback($value, $key) === false) { - break; - } - } - return $array; + return ArraySharedOps::each($array, $callback); } /** * Determine if all elements in the array pass the given truth test. * - * @param array $array The array to search. + * @param array $array The array to search. * @param callable $callback The callback to apply to each element. * @return bool Whether all elements passed the truth test. */ public static function every(array $array, callable $callback): bool { - return array_all($array, fn($value, $key) => $callback($value, $key)); + return ArraySharedOps::every($array, $callback); } /** * Get all items from the array except for those with the specified keys. * - * @param array $array The array to select from. - * @param array|string $keys The keys to exclude. - * @return array A new array with all items except for those with the specified keys. + * @param array $array The array to select from. + * @param array|string $keys The keys to exclude. + * @return array A new array with all items except for those with the specified keys. */ public static function except(array $array, array|string $keys): array { - return array_diff_key($array, array_flip((array) $keys)); + /** @var array $keyList */ + $keyList = (array) $keys; + + return array_diff_key($array, array_flip($keyList)); } + /** * Check if a given key exists in a single-dimensional array. * @@ -192,7 +224,7 @@ public static function except(array $array, array|string $keys): array * in the array, either by checking if it is set or if it exists * as a key in the array. * - * @param array $array The array to search in. + * @param array $array The array to search in. * @param int|string $key The key to check for existence. * @return bool True if the key exists in the array, false otherwise. */ @@ -201,23 +233,35 @@ public static function exists(array $array, int|string $key): bool return isset($array[$key]) || array_key_exists($key, $array); } + /** + * Return values that exist in both arrays. + * + * @param array $array + * @param array $values + * @return array + */ + public static function intersect(array $array, array $values, bool $strict = false): array + { + return ArraySingleOps::intersect($array, $values, $strict); + } + /** * Determine if an array is an associative array (i.e., has string keys). * * An associative array is an array where at least one key is a string. * - * @param array $array The array to test. + * @param array $array The array to test. * @return bool True if the array is an associative array, false otherwise. */ public static function isAssoc(array $array): bool { - return array_keys($array) !== range(0, count($array) - 1); + return $array !== [] && !array_is_list($array); } /** * Check if all values in the array are integers. * - * @param array $array The array to check. + * @param array $array The array to check. * @return bool True if all values are integers, false otherwise. */ public static function isInt(array $array): bool @@ -231,7 +275,7 @@ public static function isInt(array $array): bool * A strict list is an array where all keys are integers and are in sequence * from 0 to n-1, where n is the length of the array. * - * @param array $array The array to test. + * @param array $array The array to test. * @return bool True if the array is a strict list, false otherwise. */ public static function isList(array $array): bool @@ -242,7 +286,7 @@ public static function isList(array $array): bool /** * Determine if all values in the array are negative numbers. * - * @param array $array The array to check. + * @param array $array The array to check. * @return bool True if all values are negative, false otherwise. */ public static function isNegative(array $array): bool @@ -253,7 +297,7 @@ public static function isNegative(array $array): bool /** * Determine if all values in the array are positive numbers. * - * @param array $array The array to check. + * @param array $array The array to check. * @return bool True if all values are positive, false otherwise. */ public static function isPositive(array $array): bool @@ -264,12 +308,12 @@ public static function isPositive(array $array): bool /** * Determine if all values in the array are unique. * - * @param array $array The array to check. + * @param array $array The array to check. * @return bool True if all values are unique, false otherwise. */ public static function isUnique(array $array): bool { - return count($array) === count(array_flip($array)); + return count($array) === count(static::unique($array, true)); } /** @@ -279,10 +323,10 @@ public static function isUnique(array $array): bool * element and its key. The callback should return the value to be used * in the resulting array. * - * @param array $array The array to be mapped over. + * @param array $array The array to be mapped over. * @param callable $callback The callback function to apply to each element. * - * @return array The array with each element transformed by the callback. + * @return array The array with each element transformed by the callback. */ public static function map(array $array, callable $callback): array { @@ -290,9 +334,56 @@ public static function map(array $array, callable $callback): array foreach ($array as $key => $value) { $results[$key] = $callback($value, $key); } + return $results; } + /** + * Transform items and return a key/value map from callback results. + * + * Callback must return a one-item array like [newKey => newValue]. + * + * @param array $array + * @return array + */ + public static function mapWithKeys(array $array, callable $callback): array + { + $results = []; + + foreach ($array as $key => $value) { + $mapped = $callback($value, $key); + if (!is_array($mapped)) { + continue; + } + + foreach ($mapped as $mappedKey => $mappedValue) { + $results[self::normalizeArrayKey($mappedKey)] = $mappedValue; + } + } + + return $results; + } + + /** + * Return the largest numeric value in the array. + * + * @param array $array + */ + public static function max(array $array): float|int|null + { + return ArraySingleOps::max($array); + } + + /** + * Return the item whose callback value is the largest. + * + * @param array $array + */ + public static function maxBy(array $array, callable $callback): mixed + { + return ArraySingleOps::maxBy($array, $callback); + } + /** * Calculate the median of an array of numbers. * @@ -300,48 +391,86 @@ public static function map(array $array, callable $callback): array * odd number of elements, the median is the element at the middle index. If the list * has an even number of elements, the median is the average of the two middle elements. * - * @param array $array The array of numbers to find the median of. + * @param array $array The array of numbers to find the median of. * @return float|int The median of the numbers in the array. If the array is empty, 0 is returned. */ public static function median(array $array): float|int { - if ($array === []) { + $values = array_values( + array_filter( + $array, + static fn(mixed $value): bool => is_int($value) || is_float($value) || (is_string($value) && is_numeric($value)), + ), + ); + + if ($values === []) { return 0; } - $values = $array; + + $values = array_map(static fn(mixed $value): float => (float) $value, $values); sort($values, SORT_NUMERIC); $count = count($values); - $mid = intdiv($count, 2); + $mid = intdiv($count, 2); return ($count % 2) ? $values[$mid] : ($values[$mid - 1] + $values[$mid]) / 2; } + /** + * Return the smallest numeric value in the array. + * + * @param array $array + */ + public static function min(array $array): float|int|null + { + return ArraySingleOps::min($array); + } + + /** + * Return the item whose callback value is the smallest. + * + * @param array $array + */ + public static function minBy(array $array, callable $callback): mixed + { + return ArraySingleOps::minBy($array, $callback); + } + /** * Find the mode(s) of the array. * * The mode is the value that appears most frequently in the array. * If there are multiple modes, all of them are returned. * - * @param array $array The array to find the mode(s) of. - * @return array The mode(s) of the array. + * @param array $array The array to find the mode(s) of. + * @return array The mode(s) of the array. */ public static function mode(array $array): array { if ($array === []) { return []; } - $freq = array_count_values($array); + + $countableValues = array_filter( + $array, + static fn(mixed $value): bool => is_int($value) || is_string($value), + ); + $freq = array_count_values($countableValues); + if ($freq === []) { + return []; + } + $max = max($freq); + return array_keys(array_filter($freq, fn($c) => $c === $max)); } /** * Get only the negative numeric values from the array. * - * @param array $array The array to check. - * @return array The negative numeric values. + * @param array $array The array to check. + * @return array The negative numeric values. */ public static function negative(array $array): array { @@ -353,9 +482,9 @@ public static function negative(array $array): array * * A value is considered non-empty if it is not an empty string. * - * @param array $array The array to check. + * @param array $array The array to check. * @param bool $preserveKeys Whether to preserve original keys. - * @return array The non-empty values. + * @return array The non-empty values. */ public static function nonEmpty(array $array, bool $preserveKeys = false): array { @@ -367,11 +496,11 @@ public static function nonEmpty(array $array, bool $preserveKeys = false): array /** * Get every n-th element from the array * - * @param array $array The array to slice. + * @param array $array The array to slice. * @param int $step The "step" value (i.e. the interval between selected elements). * @param int $offset The offset from which to begin selecting elements. * - * @return array The sliced array. + * @return array The sliced array. * @throws InvalidArgumentException If step is less than 1. */ public static function nth(array $array, int $step, int $offset = 0): array @@ -379,12 +508,15 @@ public static function nth(array $array, int $step, int $offset = 0): array if ($step <= 0) { throw new InvalidArgumentException('Step must be greater than 0.'); } + if ($offset < 0) { + throw new InvalidArgumentException('Offset must be greater than or equal to 0.'); + } $results = []; $position = 0; foreach ($array as $item) { - if ($position % $step === $offset) { + if ($position >= $offset && (($position - $offset) % $step) === 0) { $results[] = $item; } $position++; @@ -398,23 +530,26 @@ public static function nth(array $array, int $step, int $offset = 0): array * * This method is the single-dimensional equivalent of ArrayMulti::only. * - * @param array $array The array to select from. - * @param array|string $keys The keys to select. - * @return array A new array with the selected keys. + * @param array $array The array to select from. + * @param array|string $keys The keys to select. + * @return array A new array with the selected keys. */ public static function only(array $array, array|string $keys): array { - return array_intersect_key($array, array_flip((array) $keys)); + /** @var array $keyList */ + $keyList = (array) $keys; + + return array_intersect_key($array, array_flip($keyList)); } /** * "Paginate" the array by slicing it into a smaller segment. * - * @param array $array The array to paginate. + * @param array $array The array to paginate. * @param int $page The page number to retrieve (1-indexed). * @param int $perPage The number of items per page. * - * @return array The paginated slice of the array. + * @return array The paginated slice of the array. */ public static function paginate(array $array, int $page, int $perPage): array { @@ -436,30 +571,20 @@ public static function paginate(array $array, int $page, int $perPage): array * The method returns an array with two elements, the first being the "passed" array, * and the second being the "failed" array. * - * @param array $array The array to partition. + * @param array $array The array to partition. * @param callable $callback The callback to use for partitioning. - * @return array An array with two elements, the first being the "passed" array, and the second being the "failed" array. + * @return array An array with two elements, the first being the "passed" array, and the second being the "failed" array. */ public static function partition(array $array, callable $callback): array { - $passed = []; - $failed = []; - - foreach ($array as $key => $value) { - if ($callback($value, $key)) { - $passed[$key] = $value; - } else { - $failed[$key] = $value; - } - } - return [$passed, $failed]; + return ArraySharedOps::partition($array, $callback); } /** * Get only the positive numeric values from the array. * - * @param array $array The array to check. - * @return array The positive numeric values. + * @param array $array The array to check. + * @return array The positive numeric values. */ public static function positive(array $array): array { @@ -473,18 +598,23 @@ public static function positive(array $array): array * in the array. If the second parameter is a key, the value is prepended with * that key. * - * @param array $array The array to prepend to. + * @param array $array The array to prepend to. * @param mixed $value The value to prepend. * @param mixed $key The key to prepend with. If null, the value is prepended as the first element. - * @return array The modified array. + * @return array The modified array. */ public static function prepend(array $array, mixed $value, mixed $key = null): array { if ($key === null) { array_unshift($array, $value); } else { + if (!is_int($key) && !is_string($key)) { + $key = self::normalizeArrayKey($key); + } + $array = [$key => $value] + $array; } + return $array; } @@ -495,7 +625,7 @@ public static function prepend(array $array, mixed $value, mixed $key = null): a * the current array value, and the current array key. It should return * the updated accumulator value. * - * @param array $array The array to reduce. + * @param array $array The array to reduce. * @param callable $callback The callback function to apply to each element. * @param mixed $initial The initial value of the accumulator. * @return mixed The reduced value. @@ -506,6 +636,7 @@ public static function reduce(array $array, callable $callback, mixed $initial = foreach ($array as $key => $value) { $accumulator = $callback($accumulator, $value, $key); } + return $accumulator; } @@ -522,15 +653,48 @@ public static function reduce(array $array, callable $callback, mixed $initial = * * The method returns an array with the same type of indices as the input array. * - * @param array $array The array to filter. + * @param array $array The array to filter. * @param mixed $callback The callback to use for filtering, or the value to compare against. Defaults to `true`. - * @return array The filtered array. + * @return array The filtered array. */ public static function reject(array $array, mixed $callback = true): array { return BaseArrayHelper::doReject($array, $callback); } + /** + * Rename keys using a map or callback. + * + * @param array $array + * @param array|callable $mapper + * @return array + */ + public static function rekey(array $array, array|callable $mapper): array + { + $results = []; + + foreach ($array as $key => $value) { + $nextKey = is_callable($mapper) + ? $mapper($key, $value) + : ($mapper[$key] ?? $key); + + $results[self::normalizeArrayKey($nextKey)] = $value; + } + + return $results; + } + + /** + * Determine if two arrays contain the same values (order-insensitive). + * + * @param array $left + * @param array $right + */ + public static function same(array $left, array $right, bool $strict = false): bool + { + return ArraySingleOps::same($left, $right, $strict); + } + /** * Search the array for a given value and return its key if found. * @@ -540,9 +704,9 @@ public static function reject(array $array, mixed $callback = true): array * strict comparison. If the value is found, its key will be returned. If the * value is not found, null will be returned. * - * @param array $array The array to search. + * @param array $array The array to search. * @param mixed $needle The value to search for, or a callable to use for - * searching. + * searching. * * @return int|string|null The key of the value if found, or null if not found. */ @@ -554,9 +718,11 @@ public static function search(array $array, mixed $needle): int|string|null return $key; } } + return null; } $foundKey = array_search($needle, $array, true); + return $foundKey === false ? null : $foundKey; } @@ -565,8 +731,8 @@ public static function search(array $array, mixed $needle): int|string|null * * Useful for destructuring an array into separate key and value arrays. * - * @param array $array The array to split. - * @return array A new array containing two child arrays: 'keys' and 'values'. + * @param array $array The array to split. + * @return array A new array containing two child arrays: 'keys' and 'values'. * @example * $data = ['a' => 1, 'b' => 2, 'c' => 3]; * $keysAndValues = ArraySingle::separate($data); @@ -588,9 +754,9 @@ public static function separate(array $array): array * seeded with the given value, used to shuffle the array, and then reset * to the current internal PHP random number generator seed. * - * @param array $array The array to shuffle. + * @param array $array The array to shuffle. * @param int|null $seed Optional seed for the Mersenne Twister. - * @return array The shuffled array. + * @return array The shuffled array. */ public static function shuffle(array $array, ?int $seed = null): array { @@ -601,6 +767,7 @@ public static function shuffle(array $array, ?int $seed = null): array \shuffle($array); \mt_srand(); } + return $array; } @@ -609,13 +776,13 @@ public static function shuffle(array $array, ?int $seed = null): array * * This method is an alias for `slice($array, $count)`. * - * @param array $array The array to skip. + * @param array $array The array to skip. * @param int $count The number of items to skip. - * @return array The skipped array. + * @return array The skipped array. */ public static function skip(array $array, int $count): array { - return static::slice($array, $count); + return ArraySharedOps::skip($array, $count); } /** @@ -628,13 +795,13 @@ public static function skip(array $array, int $count): array * and all subsequent items are also kept. * The method returns an array with the same type of indices as the input array. * - * @param array $array The array to skip. + * @param array $array The array to skip. * @param callable $callback The callback to use for skipping. - * @return array The skipped array. + * @return array The skipped array. */ public static function skipUntil(array $array, callable $callback): array { - return static::skipWhile($array, fn($value, $key) => !$callback($value, $key)); + return ArraySharedOps::skipUntil($array, $callback); } /** @@ -647,24 +814,13 @@ public static function skipUntil(array $array, callable $callback): array * and all subsequent items are also kept. * The method returns an array with the same type of indices as the input array. * - * @param array $array The array to skip. + * @param array $array The array to skip. * @param callable $callback The callback to use for skipping. - * @return array The skipped array. + * @return array The skipped array. */ public static function skipWhile(array $array, callable $callback): array { - $result = []; - $skipping = true; - - foreach ($array as $key => $value) { - if ($skipping && !$callback($value, $key)) { - $skipping = false; - } - if (!$skipping) { - $result[$key] = $value; - } - } - return $result; + return ArraySharedOps::skipWhile($array, $callback); } /** @@ -676,11 +832,11 @@ public static function skipWhile(array $array, callable $callback): array * * The method returns an array with the same type of indices as the input array. * - * @param array $array The array to slice. + * @param array $array The array to slice. * @param int $offset The offset from which to start the slice. * @param int|null $length The length of the slice. If not provided, the method will return all elements - * starting from the given offset. - * @return array The sliced array. + * starting from the given offset. + * @return array The sliced array. */ public static function slice(array $array, int $offset, ?int $length = null): array { @@ -690,13 +846,13 @@ public static function slice(array $array, int $offset, ?int $length = null): ar /** * Determine if at least one element in the array passes the given truth test. * - * @param array $array The array to search. + * @param array $array The array to search. * @param callable $callback The callback to apply to each element. * @return bool Whether at least one element passed the truth test. */ public static function some(array $array, callable $callback): bool { - return array_any($array, fn($value, $key) => $callback($value, $key)); + return array_any($array, static fn(mixed $value, int|string $key): bool => (bool) $callback($value, $key)); } /** @@ -705,7 +861,7 @@ public static function some(array $array, callable $callback): bool * If a callback is provided, it will be executed for each element in the * array and the return value will be added to the total. * - * @param array $array The array to sum. + * @param array $array The array to sum. * @param callable|null $callback The callback to execute for each element. * @return float|int The sum of all the elements in the array. */ @@ -717,9 +873,25 @@ public static function sum(array $array, ?callable $callback = null): float|int $total = 0; foreach ($array as $value) { - $total += $callback($value); + $result = $callback($value); + if (is_numeric($result)) { + $total += (float) $result; + } } - return $total; + + return fmod($total, 1.0) === 0.0 ? (int) $total : $total; + } + + /** + * Return values that exist in either array but not both. + * + * @param array $left + * @param array $right + * @return array + */ + public static function symmetricDiff(array $left, array $right, bool $strict = false): array + { + return ArraySingleOps::symmetricDiff($left, $right, $strict); } /** @@ -730,25 +902,24 @@ public static function sum(array $array, ?callable $callback = null): float|int * * The method returns an array with the same type of indices as the input array. * - * @param array $array The array to remove duplicates from. + * @param array $array The array to remove duplicates from. * @param bool $strict Whether to use strict comparison (===) or loose comparison (==). Defaults to false. - * @return array The array with all duplicate values removed. + * @return array The array with all duplicate values removed. */ public static function unique(array $array, bool $strict = false): array { - if (!$strict) { - return array_values(array_unique($array)); - } - // Manual strict approach: - $checked = []; - $result = []; - foreach ($array as $item) { - if (!in_array($item, $checked, true)) { - $checked[] = $item; - $result[] = $item; - } - } - return $result; + return ArraySingleOps::unique($array, $strict); + } + + /** + * Reindex array numerically from zero. + * + * @param array $array + * @return array + */ + public static function values(array $array): array + { + return array_values($array); } /** @@ -757,17 +928,23 @@ public static function unique(array $array, bool $strict = false): array * If the callback is omitted, the function will return all elements in the * array that are truthy. * - * @param array $array The array to search. + * @param array $array The array to search. * @param callable|null $callback The callback function to use for filtering. - * This function should take two arguments, the value and the key of each - * element in the array. The function should return true for elements that - * should be kept, and false for elements that should be discarded. + * This function should take two arguments, the value and the key of each + * element in the array. The function should return true for elements that + * should be kept, and false for elements that should be discarded. * - * @return array The filtered array. + * @return array The filtered array. */ public static function where(array $array, ?callable $callback = null): array { $flag = ($callback !== null) ? \ARRAY_FILTER_USE_BOTH : 0; + return array_filter($array, $callback ?? fn($val) => (bool) $val, $flag); } + + private static function normalizeArrayKey(mixed $value): int|string + { + return ArraySharedOps::normalizeArrayKey($value); + } } diff --git a/src/Array/ArraySingleOps.php b/src/Array/ArraySingleOps.php new file mode 100644 index 0000000..3b24ba4 --- /dev/null +++ b/src/Array/ArraySingleOps.php @@ -0,0 +1,327 @@ + $array + * @param array $needles + */ + public static function containsAll(array $array, array $needles, bool $strict): bool + { + if (!$strict) { + return array_all($needles, fn($needle) => in_array($needle, $array, false)); + } + + $lookup = self::buildStrictLookup($array); + + return array_all( + $needles, + static fn(mixed $needle): bool => isset($lookup[self::fingerprintStrict($needle)]), + ); + } + + /** + * @param array $array + * @param array $needles + */ + public static function containsAny(array $array, array $needles, bool $strict): bool + { + if (!$strict) { + return array_any($needles, fn($needle) => in_array($needle, $array, false)); + } + + $lookup = self::buildStrictLookup($array); + + return array_any( + $needles, + static fn(mixed $needle): bool => isset($lookup[self::fingerprintStrict($needle)]), + ); + } + + /** + * @param array $array + * @param array $values + * @return array + */ + public static function diff(array $array, array $values, bool $strict): array + { + if (!$strict) { + return array_filter($array, static fn(mixed $value): bool => !in_array($value, $values, false)); + } + + $lookup = self::buildStrictLookup($values); + + return array_filter( + $array, + static fn(mixed $value): bool => !isset($lookup[self::fingerprintStrict($value)]), + ); + } + + /** + * @param array $array + * @return array + */ + public static function duplicates(array $array): array + { + $strictLookup = []; + $strictCounts = []; + $duplicates = []; + + foreach ($array as $value) { + $fingerprint = self::fingerprintStrict($value); + if (!isset($strictLookup[$fingerprint])) { + $strictLookup[$fingerprint] = $value; + $strictCounts[$fingerprint] = 1; + + continue; + } + + $strictCounts[$fingerprint]++; + if ($strictCounts[$fingerprint] === 2) { + $duplicates[] = $strictLookup[$fingerprint]; + } + } + + return $duplicates; + } + + /** + * @param array $array + * @param array $values + * @return array + */ + public static function intersect(array $array, array $values, bool $strict): array + { + if (!$strict) { + return array_filter($array, static fn(mixed $value): bool => in_array($value, $values, false)); + } + + $lookup = self::buildStrictLookup($values); + + return array_filter( + $array, + static fn(mixed $value): bool => isset($lookup[self::fingerprintStrict($value)]), + ); + } + + /** + * @param array $array + */ + public static function max(array $array): float|int|null + { + return self::selectNumeric($array, pickMax: true); + } + + /** + * @param array $array + */ + public static function maxBy(array $array, callable $callback): mixed + { + return self::pickBy($array, $callback, pickMax: true); + } + + /** + * @param array $array + */ + public static function min(array $array): float|int|null + { + return self::selectNumeric($array, pickMax: false); + } + + /** + * @param array $array + */ + public static function minBy(array $array, callable $callback): mixed + { + return self::pickBy($array, $callback, pickMax: false); + } + + /** + * @param array $left + * @param array $right + */ + public static function same(array $left, array $right, bool $strict): bool + { + if (count($left) !== count($right)) { + return false; + } + + $leftCounts = self::countsByFingerprint($left, $strict); + $rightCounts = self::countsByFingerprint($right, $strict); + ksort($leftCounts); + ksort($rightCounts); + + return $leftCounts === $rightCounts; + } + + /** + * @param array $left + * @param array $right + * @return array + */ + public static function symmetricDiff(array $left, array $right, bool $strict): array + { + return array_values([ + ...self::diff($left, $right, $strict), + ...self::diff($right, $left, $strict), + ]); + } + + /** + * @param array $array + * @return array + */ + public static function unique(array $array, bool $strict): array + { + if (!$strict) { + /** @var array $unique */ + $unique = array_values(array_unique($array, \SORT_REGULAR)); + + return $unique; + } + + $seen = []; + $result = []; + foreach ($array as $item) { + $fingerprint = self::fingerprintStrict($item); + if (isset($seen[$fingerprint])) { + continue; + } + + $seen[$fingerprint] = true; + $result[] = $item; + } + + return $result; + } + + /** + * @param array $array + * @return array + */ + private static function buildStrictLookup(array $array): array + { + $lookup = []; + foreach ($array as $value) { + $lookup[self::fingerprintStrict($value)] = true; + } + + return $lookup; + } + + /** + * @param array $array + * @return array + */ + private static function countsByFingerprint(array $array, bool $strict): array + { + $counts = []; + foreach ($array as $value) { + $fingerprint = $strict ? self::fingerprintStrict($value) : self::fingerprintLoose($value); + $counts[$fingerprint] = ($counts[$fingerprint] ?? 0) + 1; + } + + return $counts; + } + + /** + * @param array $value + */ + private static function fingerprintArray(array $value): string + { + $parts = []; + foreach ($value as $key => $item) { + $parts[] = self::fingerprintStrict($key) . '=>' . self::fingerprintStrict($item); + } + + return implode('|', $parts); + } + + /** + * Build a loose-comparison-style fingerprint for set equality checks. + */ + private static function fingerprintLoose(mixed $value): string + { + return match (true) { + is_int($value), is_float($value), is_bool($value), $value === null => 'numeric:' . (float) $value, + is_string($value) => is_numeric($value) ? 'numeric:' . (float) $value : 'string:' . $value, + is_array($value) => 'array:' . self::fingerprintArray($value), + is_object($value) => 'object-value:' . self::fingerprintArray(get_object_vars($value)), + is_resource($value) => 'resource:' . get_resource_type($value) . ':' . (int) $value, + default => 'unknown:' . get_debug_type($value), + }; + } + + /** + * Build a strict fingerprint that preserves type distinctions. + */ + private static function fingerprintStrict(mixed $value): string + { + return match (true) { + $value === null => 'null:', + is_bool($value) => 'bool:' . ($value ? '1' : '0'), + is_int($value) => 'int:' . $value, + is_float($value) => 'float:' . json_encode($value, JSON_PRESERVE_ZERO_FRACTION), + is_string($value) => 'string:' . $value, + is_array($value) => 'array:' . self::fingerprintArray($value), + is_object($value) => 'object:' . $value::class . ':' . spl_object_id($value), + is_resource($value) => 'resource:' . get_resource_type($value) . ':' . (int) $value, + default => 'unknown:' . get_debug_type($value), + }; + } + + /** + * @param array $array + */ + private static function pickBy(array $array, callable $callback, bool $pickMax): mixed + { + $best = null; + $bestScore = null; + $found = false; + + foreach ($array as $key => $value) { + $score = $callback($value, $key); + if (!is_numeric($score)) { + continue; + } + + $numeric = (float) $score; + if (!$found || ($pickMax ? ($numeric > $bestScore) : ($numeric < $bestScore))) { + $best = $value; + $bestScore = $numeric; + $found = true; + } + } + + return $found ? $best : null; + } + + /** + * @param array $array + */ + private static function selectNumeric(array $array, bool $pickMax): float|int|null + { + $selected = null; + + foreach ($array as $value) { + if (!is_numeric($value)) { + continue; + } + + $numeric = (float) $value; + if ($selected === null || ($pickMax ? ($numeric > $selected) : ($numeric < $selected))) { + $selected = $numeric; + } + } + + if ($selected === null) { + return null; + } + + return fmod($selected, 1.0) === 0.0 ? (int) $selected : $selected; + } +} diff --git a/src/Array/BaseArrayHelper.php b/src/Array/BaseArrayHelper.php index 7af1697..3381df0 100644 --- a/src/Array/BaseArrayHelper.php +++ b/src/Array/BaseArrayHelper.php @@ -20,7 +20,6 @@ public static function accessible(mixed $value): bool return is_array($value) || $value instanceof ArrayAccess; } - /** * Check if all elements in the array pass the given truth test. * @@ -28,7 +27,7 @@ public static function accessible(mixed $value): bool * If the callback returns true for all elements, the function returns true. * Otherwise, it returns false. * - * @param array $array The array to be evaluated. + * @param array $array The array to be evaluated. * @param callable $callback The callback function to apply to each element. * @return bool True if all elements pass the truth test, false otherwise. */ @@ -37,7 +36,6 @@ public static function all(array $array, callable $callback): bool return static::isAll($array, $callback); } - /** * Check if at least one element in the array passes a given truth test. * @@ -45,7 +43,7 @@ public static function all(array $array, callable $callback): bool * It is provided for syntactic sugar, as it is very common to want to * check if at least one item in an array matches a given criteria. * - * @param array $array The array to check. + * @param array $array The array to check. * @param callable $callback The callback to apply to each element. * @return bool True if at least one element passes the test, false otherwise. */ @@ -54,7 +52,6 @@ public static function any(array $array, callable $callback): bool return static::haveAny($array, $callback); } - /** * Filter an array by rejecting elements based on a callback function or value. * @@ -64,9 +61,9 @@ public static function any(array $array, callable $callback): bool * If the callback is a value, elements equal to this value are rejected. * The function returns an array with the same type of indices as the input array. * - * @param array $array The array to be filtered. + * @param array $array The array to be filtered. * @param mixed $callback The callback function or value for filtering. - * @return array The array with elements rejected based on the callback or value. + * @return array The array with elements rejected based on the callback or value. */ public static function doReject(array $array, mixed $callback): array { @@ -77,14 +74,14 @@ public static function doReject(array $array, mixed $callback): array ARRAY_FILTER_USE_BOTH, ); } + return array_filter($array, fn($val) => $val != $callback); } - /** * Search the array for a given value and return its key if found. * - * @param array $array The array to search. + * @param array $array The array to search. * @param callable $callback The callback to use for searching. * * @return int|string|null The key of the value if found, or null if not found. @@ -96,10 +93,10 @@ public static function findKey(array $array, callable $callback): int|string|nul return $key; } } + return null; } - /** * Remove one or multiple array items from an array. * @@ -107,8 +104,8 @@ public static function findKey(array $array, callable $callback): int|string|nul * It then iterates over the given keys, and unsets the corresponding * items from the array. * - * @param array $array The array from which to remove items. - * @param int|string|array $keys The key or array of keys to be removed. + * @param array $array The array from which to remove items. + * @param int|string|array $keys The key or array of keys to be removed. */ public static function forget(array &$array, int|string|array $keys): void { @@ -117,12 +114,11 @@ public static function forget(array &$array, int|string|array $keys): void } } - /** * Check if all the given keys exist in the array. * - * @param array $array The array to search. - * @param int|string|array $keys The key(s) to check for existence. + * @param array $array The array to search. + * @param int|string|array $keys The key(s) to check for existence. * * @return bool True if all the given keys exist in the array, false otherwise. */ @@ -132,9 +128,9 @@ public static function has(array $array, int|string|array $keys): bool if (empty($keys)) { return false; } - return array_all($keys, fn($key) => array_key_exists($key, $array)); - } + return array_all($keys, fn(int|string $key): bool => array_key_exists($key, $array)); + } /** * Check if at least one of the given keys exists in the array. @@ -144,8 +140,8 @@ public static function has(array $array, int|string|array $keys): bool * provided array. If any key is found, the function returns * true. Otherwise, it returns false. * - * @param array $array The array to search. - * @param int|string|array $keys The key(s) to check for existence. + * @param array $array The array to search. + * @param int|string|array $keys The key(s) to check for existence. * @return bool True if at least one key exists in the array, false otherwise. */ public static function hasAny(array $array, int|string|array $keys): bool @@ -154,14 +150,14 @@ public static function hasAny(array $array, int|string|array $keys): bool if (empty($keys)) { return false; } - return array_any($keys, fn($key) => array_key_exists($key, $array)); - } + return array_any($keys, fn(int|string $key): bool => array_key_exists($key, $array)); + } /** * Determine if at least one element in the array passes the given truth test. * - * @param array $array The array to search. + * @param array $array The array to search. * @param callable $callback The callback to use for searching. * @return bool Whether at least one element passed the truth test. */ @@ -170,11 +166,10 @@ public static function haveAny(array $array, callable $callback): bool return array_any($array, fn($value, $key) => $callback($value, $key) === true); } - /** * Determine if all elements in the array pass the given truth test. * - * @param array $array The array to search. + * @param array $array The array to search. * @param callable $callback The callback to use for searching. * @return bool Whether all elements passed the truth test. */ @@ -182,6 +177,7 @@ public static function isAll(array $array, callable $callback): bool { return array_all($array, fn($value, $key) => !($callback($value, $key) === false)); } + /** * Check if an array is multi-dimensional. * @@ -198,7 +194,6 @@ public static function isMultiDimensional(mixed $array): bool && count($array) !== count($array, COUNT_RECURSIVE); } - /** * Retrieve one or multiple random items from an array. * @@ -207,7 +202,7 @@ public static function isMultiDimensional(mixed $array): bool * number of items. If you set the third argument to `true`, the * keys from the original array are preserved in the returned array. * - * @param array $array The array from which to retrieve random items. + * @param array $array The array from which to retrieve random items. * @param int|null $number The number of items to retrieve. If null, a single item is returned. * @param bool $preserveKeys Whether to preserve the keys from the original array. * @@ -224,6 +219,7 @@ public static function random(array $array, ?int $number = null, bool $preserveK if ($number === null) { $randKey = array_rand($array); + return $array[$randKey]; } @@ -239,7 +235,6 @@ public static function random(array $array, ?int $number = null, bool $preserveK return $preserveKeys ? $picked : array_values($picked); } - /** * Generate an array containing a sequence of numbers. * @@ -249,7 +244,7 @@ public static function random(array $array, ?int $number = null, bool $preserveK * @param int $start The starting number of the sequence. * @param int $end The ending number of the sequence. * @param int $step The increment between each number in the sequence. Defaults to 1. - * @return array An array containing the sequence of numbers. + * @return array An array containing the sequence of numbers. */ public static function range(int $start, int $end, int $step = 1): array { @@ -257,6 +252,7 @@ public static function range(int $start, int $end, int $step = 1): array // We could throw an exception, or return empty: return []; } + return range($start, $end, $step); } @@ -264,23 +260,22 @@ public static function range(int $start, int $end, int $step = 1): array | Additional "Sugar" Methods (Point 1) ---------------------------------------------------------------------- */ - /** * Pass the array to the given callback and return it. * * Useful for tapping into a fluent method chain for debugging. * - * @param array $array The array to be tapped. + * @param array $array The array to be tapped. * @param callable $callback The callback to apply to the array. - * @return array The original array. + * @return array The original array. */ public static function tap(array $array, callable $callback): array { $callback($array); + return $array; } - /** * Create an array of the specified length and fill it with the results of the * given callback function. If the callback is not provided, the array will be @@ -294,7 +289,7 @@ public static function tap(array $array, callable $callback): array * * @param int $number The length of the array. * @param callable|null $callback The callback function to use. - * @return array The filled array. + * @return array The filled array. */ public static function times(int $number, ?callable $callback = null): array { @@ -310,7 +305,6 @@ public static function times(int $number, ?callable $callback = null): array return $results; } - /** * Unwrap a value from an array if it contains exactly one element. * @@ -327,23 +321,24 @@ public static function unWrap(mixed $value): mixed if (!is_array($value)) { return $value; } + return (count($value) === 1) ? reset($value) : $value; } - /** * Wrap a value in an array if it's not already an array; otherwise return the array as is. * * If the value is empty, an empty array is returned. * * @param mixed $value The value to wrap. - * @return array The wrapped value. + * @return array The wrapped value. */ public static function wrap(mixed $value): array { if (empty($value)) { return []; } + return is_array($value) ? $value : [$value]; } } diff --git a/src/Array/Concerns/ArrayMultiQuerySortTrait.php b/src/Array/Concerns/ArrayMultiQuerySortTrait.php new file mode 100644 index 0000000..74bf937 --- /dev/null +++ b/src/Array/Concerns/ArrayMultiQuerySortTrait.php @@ -0,0 +1,587 @@ + $array + * @return array + */ + public static function between(array $array, string $key, float|int $from, float|int $to): array + { + return array_filter( + $array, + static fn(mixed $item): bool => is_array($item) + && ArraySingle::exists($item, $key) + && compare($item[$key], $from, '>=') + && compare($item[$key], $to, '<='), + ); + } + + /** + * Count rows grouped by key/callback buckets. + * + * @param array $array + * @return array + */ + public static function countBy(array $array, string|callable $groupBy): array + { + $counts = []; + + foreach ($array as $key => $row) { + $bucket = is_callable($groupBy) + ? $groupBy($row, $key) + : ((is_array($row) && array_key_exists($groupBy, $row)) ? $row[$groupBy] : '_undefined'); + + $normalized = self::normalizeArrayKey($bucket); + $counts[$normalized] = ($counts[$normalized] ?? 0) + 1; + } + + return $counts; + } + + /** + * Return the first row where the key comparison matches. + * + * @param array $array + */ + public static function firstWhere( + array $array, + string $key, + mixed $operator = null, + mixed $value = null, + mixed $default = null, + ): mixed { + if ($value === null && $operator !== null) { + $value = $operator; + $operator = null; + } + $operator = is_string($operator) ? $operator : null; + + foreach ($array as $row) { + if ( + is_array($row) + && ArraySingle::exists($row, $key) + && compare($row[$key], $value, $operator) + ) { + return $row; + } + } + + return $default; + } + + /** + * Group a 2D array by a given column or callback. + * + * @param array $array + * @return array + */ + public static function groupBy(array $array, string|callable $groupBy, bool $preserveKeys = false): array + { + $results = []; + foreach ($array as $key => $row) { + $gKey = null; + if (is_callable($groupBy)) { + $gKey = $groupBy($row, $key); + } elseif (is_array($row) && isset($row[$groupBy])) { + $gKey = $row[$groupBy]; + } else { + $gKey = '_undefined'; + } + $groupKey = self::normalizeArrayKey($gKey); + + if ($preserveKeys) { + $results[$groupKey][$key] = $row; + } else { + $results[$groupKey][] = $row; + } + } + + return $results; + } + + /** + * Alias of keyBy(). + * + * @param array $array + * @return array + */ + public static function indexBy(array $array, string|callable $indexBy): array + { + return static::keyBy($array, $indexBy); + } + + /** + * Key rows by a derived key (last write wins on duplicate keys). + * + * @param array $array + * @return array + */ + public static function keyBy(array $array, string|callable $keyBy): array + { + $results = []; + + foreach ($array as $index => $row) { + $resolved = is_callable($keyBy) + ? $keyBy($row, $index) + : ((is_array($row) && array_key_exists($keyBy, $row)) ? $row[$keyBy] : '_undefined'); + + $results[self::normalizeArrayKey($resolved)] = $row; + } + + return $results; + } + + /** + * Return the last item in a 2D array or single-dim array, depending on usage. + * + * @param array $array + */ + public static function last(array $array, ?callable $callback = null, mixed $default = null): mixed + { + if ($callback === null) { + return empty($array) ? $default : end($array); + } + + return static::first(array_reverse($array, true), $callback, $default); + } + + /** + * Transform rows and return a key/value map from callback results. + * + * Callback must return a one-item array like [newKey => newValue]. + * + * @param array $array + * @return array + */ + public static function mapWithKeys(array $array, callable $callback): array + { + $results = []; + + foreach ($array as $key => $row) { + $mapped = $callback($row, $key); + if (!is_array($mapped)) { + continue; + } + + foreach ($mapped as $mappedKey => $mappedValue) { + $results[self::normalizeArrayKey($mappedKey)] = $mappedValue; + } + } + + return $results; + } + + /** + * Return the maximum numeric row value by key/callback. + * + * @param array $array + */ + public static function max(array $array, string|callable $keyOrCallback): float|int|null + { + $max = self::selectExtremeValue($array, $keyOrCallback, pickMax: true); + + if ($max === null) { + return null; + } + + return fmod($max, 1.0) === 0.0 ? (int) $max : $max; + } + + /** + * Return the row with the largest numeric score by key/callback. + * + * @param array $array + */ + public static function maxBy(array $array, string|callable $keyOrCallback): mixed + { + return self::selectExtremeRow($array, $keyOrCallback, pickMax: true); + } + + /** + * Return the minimum numeric row value by key/callback. + * + * @param array $array + */ + public static function min(array $array, string|callable $keyOrCallback): float|int|null + { + $min = self::selectExtremeValue($array, $keyOrCallback, pickMax: false); + + if ($min === null) { + return null; + } + + return fmod($min, 1.0) === 0.0 ? (int) $min : $min; + } + + /** + * Return the row with the smallest numeric score by key/callback. + * + * @param array $array + */ + public static function minBy(array $array, string|callable $keyOrCallback): mixed + { + return self::selectExtremeRow($array, $keyOrCallback, pickMax: false); + } + + /** + * @param array $array + * @return array + */ + public static function pluck(array $array, string $column, ?string $indexBy = null): array + { + $results = []; + foreach ($array as $row) { + if (!is_array($row) || !array_key_exists($column, $row)) { + continue; + } + + $value = $row[$column]; + if ($indexBy !== null && array_key_exists($indexBy, $row)) { + $results[self::normalizeArrayKey($row[$indexBy])] = $value; + } else { + $results[] = $value; + } + } + + return $results; + } + + /** + * Return an array with all values that do not pass the given callback. + * + * @param array $array + * @return array + */ + public static function reject(array $array, mixed $callback = true): array + { + if (is_callable($callback)) { + return array_filter($array, fn($row, $key) => !$callback($row, $key), \ARRAY_FILTER_USE_BOTH); + } + + return array_filter($array, fn($row) => $row != $callback); + } + + /** + * Check if the array (of rows) contains at least one row matching a condition. + * + * @param array $array + */ + public static function some(array $array, callable $callback): bool + { + return array_any($array, static fn(mixed $row, int|string $key): bool => (bool) $callback($row, $key)); + } + + /** + * Sort a 2D array by a specified column or using a callback function. + * + * @param array $array + * @return array + */ + public static function sortBy( + array $array, + string|callable $by, + bool $desc = false, + int $options = \SORT_REGULAR, + ): array { + uasort($array, function ($a, $b) use ($by, $desc, $options) { + $valA = is_callable($by) ? $by($a) : (is_array($a) ? ($a[$by] ?? null) : null); + $valB = is_callable($by) ? $by($b) : (is_array($b) ? ($b[$by] ?? null) : null); + + $comparison = self::compareSortValues($valA, $valB, $options); + + return $desc ? -$comparison : $comparison; + }); + + return $array; + } + + /** + * @param array $array + * @return array + */ + public static function sortByDesc(array $array, string|callable $by, int $options = \SORT_REGULAR): array + { + return static::sortBy($array, $by, true, $options); + } + + /** + * Recursively sort a multidimensional array by keys/values. + * + * @param array $array + * @return array + */ + public static function sortRecursive(array $array, int $options = \SORT_REGULAR, bool $descending = false): array + { + foreach ($array as &$value) { + if (is_array($value)) { + $value = static::sortRecursive($value, $options, $descending); + } + } + + if (ArraySingle::isAssoc($array)) { + $descending + ? krsort($array, $options) + : ksort($array, $options); + } else { + usort( + $array, + static fn(mixed $left, mixed $right): int => $descending + ? -self::compareSortValues($left, $right, $options) + : self::compareSortValues($left, $right, $options), + ); + } + + return $array; + } + + /** + * Calculate the sum of an array of values. + * + * @param array $array + */ + public static function sum(array $array, string|callable|null $keyOrCallback = null): float|int + { + $total = 0; + foreach ($array as $row) { + $total += self::extractSummableValue($row, $keyOrCallback); + } + + return fmod($total, 1.0) === 0.0 ? (int) $total : $total; + } + + /** + * Filter a 2D array by a single key's comparison (like "where 'age' > 18"). + * + * @param array $array + * @return array + */ + public static function where(array $array, string $key, mixed $operator = null, mixed $value = null): array + { + if ($value === null && $operator !== null) { + $value = $operator; + $operator = null; + } + $operator = is_string($operator) ? $operator : null; + + return array_filter( + $array, + static fn(mixed $item): bool => is_array($item) + && ArraySingle::exists($item, $key) + && compare($item[$key], $value, $operator), + ); + } + + /** + * Filter a 2D array by a custom callback function on each row. + * + * @param array $array + */ + public static function whereCallback(array $array, ?callable $callback = null, mixed $default = null): mixed + { + if ($callback === null) { + return empty($array) ? $default : $array; + } + + return array_filter($array, static fn(mixed $item, int|string $index): bool => (bool) $callback($item, $index), \ARRAY_FILTER_USE_BOTH); + } + + /** + * Filter rows where "column" matches one of the given values. + * + * @param array $array + * @param array $values + * @return array + */ + public static function whereIn(array $array, string $key, array $values, bool $strict = false): array + { + return array_filter( + $array, + static fn(mixed $row): bool => is_array($row) + && array_key_exists($key, $row) + && in_array($row[$key], $values, $strict), + ); + } + + /** + * Filter rows where "column" does NOT match one of the given values. + * + * @param array $array + * @param array $values + * @return array + */ + public static function whereNotIn(array $array, string $key, array $values, bool $strict = false): array + { + return array_filter( + $array, + static fn(mixed $row): bool => !is_array($row) + || !array_key_exists($key, $row) + || !in_array($row[$key], $values, $strict), + ); + } + + /** + * Filter rows where a column is not null. + * + * @param array $array + * @return array + */ + public static function whereNotNull(array $array, string $key): array + { + return array_filter($array, static fn(mixed $row): bool => is_array($row) && isset($row[$key])); + } + + /** + * Filter rows where a column is null. + * + * @param array $array + * @return array + */ + public static function whereNull(array $array, string $key): array + { + return array_filter( + $array, + static fn(mixed $row): bool => is_array($row) + && !empty($row) + && array_key_exists($key, $row) + && $row[$key] === null, + ); + } + + private static function asNumeric(mixed $value): float + { + if (is_int($value) || is_float($value)) { + return (float) $value; + } + + if (is_string($value) && is_numeric($value)) { + return (float) $value; + } + + return 0.0; + } + + private static function asString(mixed $value): string + { + return ArraySharedOps::asString($value); + } + + /** + * Compare two values according to PHP sort options. + */ + private static function compareSortValues(mixed $left, mixed $right, int $options): int + { + if ($left === $right) { + return 0; + } + + $caseInsensitive = (bool) ($options & \SORT_FLAG_CASE); + $baseOption = $options & ~\SORT_FLAG_CASE; + + return match ($baseOption) { + \SORT_NUMERIC => self::asNumeric($left) <=> self::asNumeric($right), + \SORT_STRING => $caseInsensitive + ? strcasecmp(self::asString($left), self::asString($right)) + : strcmp(self::asString($left), self::asString($right)), + \SORT_NATURAL => $caseInsensitive + ? strnatcasecmp(self::asString($left), self::asString($right)) + : strnatcmp(self::asString($left), self::asString($right)), + \SORT_LOCALE_STRING => strcoll(self::asString($left), self::asString($right)), + default => $left <=> $right, + }; + } + + private static function extractComparableValue(mixed $row, string|callable $keyOrCallback): ?float + { + if (is_callable($keyOrCallback)) { + $result = $keyOrCallback($row); + + return is_numeric($result) ? (float) $result : null; + } + + if (is_array($row) && array_key_exists($keyOrCallback, $row) && is_numeric($row[$keyOrCallback])) { + return (float) $row[$keyOrCallback]; + } + + return null; + } + + private static function extractSummableValue(mixed $row, string|callable|null $keyOrCallback): float + { + if ($keyOrCallback === null) { + return is_numeric($row) ? (float) $row : 0.0; + } + + if (is_callable($keyOrCallback)) { + $result = $keyOrCallback($row); + + return is_numeric($result) ? (float) $result : 0.0; + } + + if (is_array($row) && isset($row[$keyOrCallback]) && is_numeric($row[$keyOrCallback])) { + return (float) $row[$keyOrCallback]; + } + + return 0.0; + } + + private static function normalizeArrayKey(mixed $value): int|string + { + return ArraySharedOps::normalizeArrayKey($value); + } + + /** + * @param array $array + */ + private static function selectExtremeRow(array $array, string|callable $keyOrCallback, bool $pickMax): mixed + { + $bestRow = null; + $bestScore = null; + $found = false; + + foreach ($array as $row) { + $score = self::extractComparableValue($row, $keyOrCallback); + if ($score === null) { + continue; + } + + if (!$found || ($pickMax ? ($score > $bestScore) : ($score < $bestScore))) { + $bestRow = $row; + $bestScore = $score; + $found = true; + } + } + + return $found ? $bestRow : null; + } + + /** + * @param array $array + */ + private static function selectExtremeValue(array $array, string|callable $keyOrCallback, bool $pickMax): ?float + { + $selected = null; + + foreach ($array as $row) { + $value = self::extractComparableValue($row, $keyOrCallback); + if ($value === null) { + continue; + } + + if ($selected === null || ($pickMax ? ($value > $selected) : ($value < $selected))) { + $selected = $value; + } + } + + return $selected; + } +} diff --git a/src/Array/Concerns/DotNotationPublicApiTrait.php b/src/Array/Concerns/DotNotationPublicApiTrait.php new file mode 100644 index 0000000..ff8a9fb --- /dev/null +++ b/src/Array/Concerns/DotNotationPublicApiTrait.php @@ -0,0 +1,300 @@ + $array + * @return array + */ + public static function all(array $array): array + { + return $array; + } + + /** + * @param array $array + * @return array + */ + public static function arrayValue(array $array, string $key, mixed $default = null): array + { + $value = self::get($array, $key, $default); + if (!is_array($value)) { + throw new InvalidArgumentException('Expected array, got ' . get_debug_type($value)); + } + + return $value; + } + + /** + * @param array $array + */ + public static function boolean(array $array, string $key, mixed $default = null): bool + { + $value = self::get($array, $key, $default); + if (!is_bool($value)) { + throw new InvalidArgumentException('Expected bool, got ' . get_debug_type($value)); + } + + return $value; + } + + /** + * @param array $array + * @return array + */ + public static function expand(array $array): array + { + $results = []; + foreach ($array as $key => $value) { + self::set($results, $key, $value); + } + + return $results; + } + + /** + * @param array $array + * @param array|string $keys + */ + public static function fill(array &$array, array|string $keys, mixed $value = null): void + { + self::set($array, $keys, $value, false); + } + + /** + * @param array $array + * @return array + */ + public static function flatten(array $array, string $prepend = ''): array + { + $results = []; + self::flattenInto($array, $prepend, $results); + + return $results; + } + + /** + * @param array $array + */ + public static function float(array $array, string $key, mixed $default = null): float + { + $value = self::get($array, $key, $default); + if (!is_float($value)) { + throw new InvalidArgumentException('Expected float, got ' . get_debug_type($value)); + } + + return $value; + } + + /** + * @param array $target + * @param array|string|int|null $keys + */ + public static function forget(array &$target, array|string|int|null $keys): void + { + if ($keys === null || $keys === []) { + return; + } + + if (is_array($keys)) { + foreach ($keys as $path) { + self::forget($target, $path); + } + + return; + } + + self::forgetBySegments($target, self::splitPath((string) $keys)); + } + + /** + * @param array $array + * @param array|int|string|null $keys + */ + public static function get(array $array, array|int|string|null $keys = null, mixed $default = null): mixed + { + if ($keys === null) { + return $array; + } + + if (is_array($keys)) { + $results = []; + foreach ($keys as $k) { + $resolvedKey = (string) $k; + $results[$resolvedKey] = self::getValue($array, $resolvedKey, $default); + } + + return $results; + } + + return self::getValue($array, $keys, $default); + } + + /** + * @param array $array + * @param array|string $keys + */ + public static function has(array $array, array|string $keys): bool + { + if (empty($array) || empty($keys)) { + return false; + } + + if (is_string($keys) && ArraySingle::exists($array, $keys)) { + return true; + } + + $keys = (array) $keys; + $missing = self::missing(); + foreach ($keys as $key) { + $resolvedKey = (string) $key; + if (ArraySingle::exists($array, $resolvedKey)) { + continue; + } + if (self::segmentExact($array, $resolvedKey, $missing) === $missing) { + return false; + } + } + + return true; + } + + /** + * @param array $array + * @param array|string $keys + */ + public static function hasAny(array $array, array|string $keys): bool + { + if (empty($array) || empty($keys)) { + return false; + } + + $keys = (array) $keys; + + return array_any($keys, static fn(int|string $key): bool => self::has($array, (string) $key)); + } + + /** + * @param array $array + */ + public static function integer(array $array, string $key, mixed $default = null): int + { + $value = self::get($array, $key, $default); + if (!is_int($value)) { + throw new InvalidArgumentException('Expected int, got ' . get_debug_type($value)); + } + + return $value; + } + + /** + * @param array $array + */ + public static function offsetExists(array $array, string $key): bool + { + return self::has($array, $key); + } + + /** + * @param array $array + */ + public static function offsetGet(array $array, string $key): mixed + { + return self::get($array, $key); + } + + /** + * @param array $array + */ + public static function offsetSet(array &$array, string $key, mixed $value): void + { + self::set($array, $key, $value); + } + + /** + * @param array $array + */ + public static function offsetUnset(array &$array, string $key): void + { + self::forget($array, $key); + } + + /** + * @param array $array + * @param array|string $keys + * @return array + */ + public static function pluck(array $array, array|string $keys, mixed $default = null): array + { + $keys = (array) $keys; + $results = []; + + foreach ($keys as $key) { + $results[$key] = self::get($array, $key, $default); + } + + return $results; + } + + /** + * @param array $array + * @param array|string|null $keys + */ + public static function set(array &$array, array|string|null $keys = null, mixed $value = null, bool $overwrite = true): bool + { + if ($keys === null) { + $array = (array) $value; + + return true; + } + + if (is_array($keys)) { + foreach ($keys as $k => $val) { + $working = $array; + self::setValue($working, (string) $k, $val, $overwrite); + if (is_array($working)) { + $array = $working; + } + } + } else { + $working = $array; + self::setValue($working, $keys, $value, $overwrite); + if (is_array($working)) { + $array = $working; + } + } + + return true; + } + + /** + * @param array $array + */ + public static function string(array $array, string $key, mixed $default = null): string + { + $value = self::get($array, $key, $default); + if (!is_string($value)) { + throw new InvalidArgumentException('Expected string, got ' . get_debug_type($value)); + } + + return $value; + } + + /** + * @param array $array + * @return array + */ + public static function tap(array $array, callable $callback): array + { + $callback($array); + + return $array; + } +} diff --git a/src/Array/DotNotation.php b/src/Array/DotNotation.php index fd1212b..c236d19 100644 --- a/src/Array/DotNotation.php +++ b/src/Array/DotNotation.php @@ -4,542 +4,110 @@ namespace Infocyph\ArrayKit\Array; -use InvalidArgumentException; +use Infocyph\ArrayKit\Array\Concerns\DotNotationPublicApiTrait; class DotNotation { - /** - * Get all the given array. - */ - public static function all(array $array): array - { - return $array; - } + use DotNotationPublicApiTrait; /** - * Retrieve an array value from the array using a dot-notation key. - * - * This method tries to fetch a value from the given array with the specified key. - * If the value is not an array, an InvalidArgumentException is thrown. - * If the key is not found, the default value is returned. - * - * @param array $array The array to retrieve the value from. - * @param string $key The dot-notation key to use for retrieval. - * @param mixed $default The default value to return if the key is not found. - * @return array The retrieved array value. - * - * @throws InvalidArgumentException If the retrieved value is not an array. - */ - public static function arrayValue(array $array, string $key, mixed $default = null): array - { - $value = static::get($array, $key, $default); - if (! is_array($value)) { - throw new InvalidArgumentException('Expected array, got ' . get_debug_type($value)); - } - - return $value; - } - - /** - * Retrieve a boolean value from the array using a dot-notation key. - * - * This method tries to fetch a value from the given array with the specified key. - * If the value is not a boolean, an InvalidArgumentException is thrown. - * If the key is not found, the default value is returned. - * - * @param array $array The array to retrieve the value from. - * @param string $key The dot-notation key to use for retrieval. - * @param mixed $default The default value to return if the key is not found. - * @return bool The retrieved boolean value. - * - * @throws InvalidArgumentException If the retrieved value is not a boolean. + * @param array $array + * @param array $result */ - public static function boolean(array $array, string $key, mixed $default = null): bool + private static function flattenInto(array $array, string $prepend, array &$result): void { - $value = static::get($array, $key, $default); - if (! is_bool($value)) { - throw new InvalidArgumentException('Expected bool, got ' . get_debug_type($value)); - } - - return $value; - } - - /** - * Expands a flattened array (created by flatten) back into a nested structure. - * - * @param array $array A flattened array, where each key is a string with dot - * notation representing the nested keys. - * @return array A nested array with the same values as the input but with the - * nested structure restored. - */ - public static function expand(array $array): array - { - $results = []; foreach ($array as $key => $value) { - static::set($results, $key, $value); - } - - return $results; - } + if (is_array($value) && !empty($value)) { + self::flattenInto($value, $prepend . $key . '.', $result); - /** - * Fill in data where missing (like set, but doesn't overwrite existing keys). - * - * @param array $array The array to fill in. - * @param array|string $keys The key(s) to fill in. - * @param mixed $value The value to set if missing. - */ - public static function fill(array &$array, array|string $keys, mixed $value = null): void - { - static::set($array, $keys, $value, false); - } - /** - * Flattens a multidimensional array to a single level, using dot notation to - * represent nested keys. - * - * @param array $array The multidimensional array to flatten. - * @param string $prepend A string to prepend to the keys of the flattened array. - * @return array A flattened array with all nested arrays collapsed to the same level. - */ - public static function flatten(array $array, string $prepend = ''): array - { - $results = []; - - foreach ($array as $key => $value) { - if (is_array($value) && ! empty($value)) { - $results = array_merge( - $results, - static::flatten($value, $prepend . $key . '.'), - ); - } else { - $results[$prepend . $key] = $value; + continue; } - } - return $results; - } - - /** - * Retrieve a float value from the array using a dot-notation key. - * - * This method tries to fetch a value from the given array with the specified key. - * If the value is not a float, an InvalidArgumentException is thrown. - * If the key is not found, the default value is returned. - * - * @param array $array The array to retrieve the value from. - * @param string $key The dot-notation key to use for retrieval. - * @param mixed $default The default value to return if the key is not found. - * @return float The retrieved float value. - * - * @throws InvalidArgumentException If the retrieved value is not a float. - */ - public static function float(array $array, string $key, mixed $default = null): float - { - $value = static::get($array, $key, $default); - if (! is_float($value)) { - throw new InvalidArgumentException('Expected float, got ' . get_debug_type($value)); + $result[$prepend . $key] = $value; } - - return $value; } /** - * Remove one or multiple items from an array or object using dot notation. - * - * This method supports wildcard and dot notation for nested arrays or objects. - * If a wildcard ('*') is encountered, it applies the forget operation to each - * accessible element. For objects, it unsets the specified property. - * - * @param array $target The target array or object to remove items from. - * @param array|string|int|null $keys The key(s) or path(s) to be removed. - * Supports dot notation and wildcards. + * @param array $array + * @param array $segments */ - public static function forget(array &$target, array|string|int|null $keys): void + private static function forgetBySegments(array &$array, array $segments): void { - if ($keys === null || $keys === []) { + if ($segments === []) { return; } - // Convert keys to segments. - $segments = is_array($keys) ? $keys : explode('.', (string) $keys); - $segment = array_shift($segments); - - match (true) { - // Case 1: Wildcard on an accessible array. - $segment === '*' && BaseArrayHelper::accessible($target) => count($segments) > 0 ? static::forgetEach($target, $segments) : null, - - // Case 2: Target is array-accessible (normal array). - BaseArrayHelper::accessible($target) => count($segments) > 0 && ArraySingle::exists($target, $segment) - ? static::forget($target[$segment], $segments) - : BaseArrayHelper::forget($target, $segment), - - // Case 3: Target is an object. - is_object($target) => count($segments) > 0 && isset($target->{$segment}) - ? static::forget($target->{$segment}, $segments) - : (isset($target->{$segment}) ? static::unsetProperty($target, $segment) : null), - - default => null, - }; - } - - /** - * Get one or multiple items from the array using dot notation. - * - * The following cases are handled: - * - If no key is provided, the entire array is returned. - * - If an array of keys is provided, all values are returned in an array. - * - If a single key is provided, the value is returned directly. - * - * @param array $array The array to retrieve items from. - * @param array|int|string|null $keys The key(s) to retrieve. - * @param mixed $default The default value to return if the key is not found. - * @return mixed The retrieved value(s). - */ - public static function get(array $array, array|int|string|null $keys = null, mixed $default = null): mixed - { - // If no key, return entire array - if ($keys === null) { - return $array; - } - - // If multiple keys requested, gather each value: - if (is_array($keys)) { - $results = []; - foreach ($keys as $k) { - $results[$k] = static::getValue($array, $k, $default); - } - - return $results; - } - - // single key - return static::getValue($array, $keys, $default); - } - - /** - * Determine if the given key or keys exist in the array. - * - * This method is the dot notation aware version of ArraySingle::has. - * - * @param array $array The array to search. - * @param array|string $keys The key(s) to check for existence. - * @return bool True if all the given keys exist in the array, false otherwise. - */ - public static function has(array $array, array|string $keys): bool - { - if (empty($array) || empty($keys)) { - return false; - } - - // If single string key and found top-level: - if (is_string($keys) && ArraySingle::exists($array, $keys)) { - return true; + $segment = self::shiftSegment($segments); + if ($segment === null) { + return; } - $keys = (array) $keys; - foreach ($keys as $key) { - if (ArraySingle::exists($array, $key)) { - continue; + if ($segment === '*') { + if ($segments !== []) { + self::forgetEach($array, $segments); } - // Fall back to a simple segment check (no wildcard) - if (! static::segmentExact($array, $key, false)) { - return false; - } - } - - return true; - } - - /** - * Check if *any* of the given keys exist (no wildcard). - * - * @param array $array The array to search. - * @param array|string $keys The key(s) to check for existence. - * @return bool True if at least one key exists - */ - public static function hasAny(array $array, array|string $keys): bool - { - if (empty($array) || empty($keys)) { - return false; - } - - $keys = (array) $keys; - return array_any($keys, fn($key) => static::has($array, $key)); - } - - /** - * Retrieve an integer value from the array using a dot-notation key. - * - * This method tries to fetch a value from the given array with the specified key. - * If the value is not an integer, an InvalidArgumentException is thrown. - * If the key is not found, the default value is returned. - * - * @param array $array The array to retrieve the value from. - * @param string $key The dot-notation key to use for retrieval. - * @param mixed $default The default value to return if the key is not found. - * @return int The retrieved integer value. - * - * @throws InvalidArgumentException If the retrieved value is not an integer. - */ - public static function integer(array $array, string $key, mixed $default = null): int - { - $value = static::get($array, $key, $default); - if (! is_int($value)) { - throw new InvalidArgumentException('Expected int, got ' . get_debug_type($value)); - } - - return $value; - } - - /** - * Check if a given key exists in the array using dot notation. - * - * This method determines if the specified key is present - * within the provided array. It leverages the dot notation - * to access nested data structures. - * - * @param array $array The array to search. - * @param string $key The dot-notation key to check for existence. - * @return bool True if the key exists, false otherwise. - */ - public static function offsetExists(array $array, string $key): bool - { - return static::has($array, $key); - } - - /** - * Retrieves a value from the array using dot notation. - * - * This method is a part of the ArrayAccess implementation. - * - * @param array $array The array to retrieve the value from. - * @param string $key The dot-notation key to retrieve. - * @return mixed The retrieved value. - * - * @see Infocyph\ArrayKit\Array\DotNotation::get() - */ - public static function offsetGet(array $array, string $key): mixed - { - return static::get($array, $key); - } - - /** - * Set a value in the array using dot notation. - * - * This method is a part of the ArrayAccess implementation. - * - * @param array &$array The array to set the value in. - * @param string $key The dot-notation key to set. - * @param mixed $value The value to set. - * - * @see Infocyph\ArrayKit\Array\DotNotation::set() - */ - public static function offsetSet(array &$array, string $key, mixed $value): void - { - static::set($array, $key, $value); - } - - /** - * Unset a value in the array using dot notation. - * - * This method removes a value from the provided array - * at the specified dot-notation key. It leverages the - * forget logic to handle nested arrays and supports - * wildcard paths. - * - * @param array &$array The array from which to unset the value. - * @param string $key The dot-notation key of the value to unset. - */ - public static function offsetUnset(array &$array, string $key): void - { - static::forget($array, $key); - } - /** - * Pluck one or more values from an array. - * - * This method allows you to retrieve one or more values from an array - * using dot-notation keys. - * - * @param array $array The array to retrieve values from. - * @param array|string $keys The key(s) to retrieve. - * @param mixed $default The default value to return if the key is not found. - * @return array The retrieved values. - */ - public static function pluck(array $array, array|string $keys, mixed $default = null): array - { - $keys = (array) $keys; - $results = []; - - foreach ($keys as $key) { - $results[$key] = static::get($array, $key, $default); - } - - return $results; - } - - /** - * Set one or multiple items in the array using dot notation. - * - * If no key is provided, the entire array is replaced with $value. - * If an array of key-value pairs is provided, each value is set. - * If a single key is provided, the value is set directly. - * - * @param array $array The array to set items in. - * @param array|string|null $keys The key(s) to set. - * @param mixed $value The value to set. - * @param bool $overwrite If true, existing values are overwritten. If false, existing values are preserved. - * @return bool True on success - */ - public static function set(array &$array, array|string|null $keys = null, mixed $value = null, bool $overwrite = true): bool - { - // If no key, replace entire array with $value - if ($keys === null) { - $array = (array) $value; - - return true; - } - - if (is_array($keys)) { - // multiple sets - foreach ($keys as $k => $val) { - static::setValue($array, $k, $val, $overwrite); - } - } else { - static::setValue($array, $keys, $value, $overwrite); + return; } - return true; - } + $normalized = self::unescapeSegment($segment); + if ($segments !== [] && ArraySingle::exists($array, $normalized) && is_array($array[$normalized])) { + self::forgetBySegments($array[$normalized], $segments); - /** - * Retrieve a string value from the array using a dot-notation key. - * - * This function attempts to retrieve a value from the given array - * using the specified key. If the retrieved value is not of type - * string, an InvalidArgumentException is thrown. If the key is not - * found, the default value is returned. - * - * @param array $array The array to retrieve the value from. - * @param string $key The dot-notation key to use for retrieval. - * @param mixed $default The default value to return if the key is not found. - * @return string The retrieved string value. - * - * @throws InvalidArgumentException If the retrieved value is not a string. - */ - public static function string(array $array, string $key, mixed $default = null): string - { - $value = static::get($array, $key, $default); - if (! is_string($value)) { - throw new InvalidArgumentException('Expected string, got ' . get_debug_type($value)); + return; } - return $value; - } - - /** - * Pass the array to the given callback and return it. - * - * Useful for tapping into a fluent method chain for debugging. - * - * @param array $array The array to be tapped. - * @param callable $callback The callback to apply to the array. - * @return array The original array. - */ - public static function tap(array $array, callable $callback): array - { - $callback($array); - - return $array; - } - - /** - * Access a segment in a target array or object. - * - * This method takes a target array or object, a segment, and a default value. - * It returns the value of the segment in the target if it exists, or the default - * value if it does not. It supports both array and object access. Array access - * is attempted first, then object access. - * - * @param mixed $target The target array or object to access. - * @param mixed $segment The segment to access. - * @param mixed $default The default value to return if the segment does not exist. - * @return mixed The value of the segment in the target, or the default value. - */ - private static function accessSegment(mixed $target, mixed $segment, mixed $default): mixed - { - return match (true) { - BaseArrayHelper::accessible($target) && ArraySingle::exists($target, $segment) => $target[$segment], - is_object($target) && isset($target->{$segment}) => $target->{$segment}, - default => static::value($default), - }; + BaseArrayHelper::forget($array, $normalized); } /** * Recursively apply the forget logic to each element in an array. * - * This function iterates over each element of the provided array - * and applies the forget operation using the given segments. - * - * @param array $array The array whose elements will be processed. - * @param array $segments The segments to use for the forget operation. + * @param array $array + * @param array $segments */ private static function forgetEach(array &$array, array $segments): void { foreach ($array as &$inner) { - static::forget($inner, $segments); + if (is_array($inner)) { + self::forgetBySegments($inner, $segments); + } } } /** * Retrieve a value from the array using dot notation. - * - * This method supports retrieving values from the given array - * using dot-notation keys. It will traverse the array as necessary - * to retrieve the value. If the key is not found, the default value - * is returned. - * - * @param mixed $target The array/object to retrieve the value from. - * @param int|string $key The key to retrieve (supports dot notation). - * @param mixed $default The default value to return if the key is not found. - * @return mixed The retrieved value. */ private static function getValue(mixed $target, int|string $key, mixed $default): mixed { - if (is_int($key) || ArraySingle::exists($target, $key)) { - // Return top-level or integer index - return $target[$key] ?? static::value($default); + if (is_array($target) && (is_int($key) || ArraySingle::exists($target, $key))) { + return $target[$key]; } - if (! str_contains($key, '.')) { - // If no dot path - return static::value($default); + + $keyPath = (string) $key; + if (!str_contains($keyPath, '.') && !str_contains($keyPath, '\\')) { + return self::value($default); } - return static::traverseGet($target, explode('.', $key), $default); + $missing = self::missing(); + $resolved = self::traverseGet($target, self::splitPath($keyPath), $default, $missing); + + return $resolved === $missing ? self::value($default) : $resolved; } /** * Sets values in the target using dot-notation with wildcard support. * - * This method handles cases where the first segment in the dot-notation key - * is a wildcard ('*'). It iterates over each element of the target, applying - * the remaining segments to set the specified value. If segments are present, - * it continues setting values recursively. If the overwrite flag is true and - * no segments remain, it sets each element in the target to the provided value. - * - * @param mixed &$target The target to set values in, typically an array. - * @param array $segments The remaining segments of the dot-notation key. - * @param mixed $value The value to set. - * @param bool $overwrite If true, overwrite existing values. + * @param array $segments */ private static function handleWildcardSet(mixed &$target, array $segments, mixed $value, bool $overwrite): void { - if (! BaseArrayHelper::accessible($target)) { + if (!is_array($target)) { $target = []; } - if (! empty($segments)) { + if (!empty($segments)) { foreach ($target as &$inner) { - static::setValue($inner, implode('.', $segments), $value, $overwrite); + self::setValueBySegments($inner, $segments, $value, $overwrite); } } elseif ($overwrite) { foreach ($target as &$inner) { @@ -549,177 +117,97 @@ private static function handleWildcardSet(mixed &$target, array $segments, mixed } /** - * Normalize a dot-notation segment by replacing escaped values and resolving - * special values such as '{first}' and '{last}'. - * - * @param string $segment The segment of the dot-notation key. - * @param mixed $target The target array or object to resolve against. - * @return mixed The normalized segment. + * Get a stable sentinel that represents a missing key path. */ - private static function normalizeSegment(string $segment, mixed $target): mixed + private static function missing(): object { - return match ($segment) { - '\\*' => '*', - '\\{first}' => '{first}', - '{first}' => static::resolveFirst($target), - '\\{last}' => '{last}', - '{last}' => static::resolveLast($target), - default => $segment, - }; + static $missing; + + if (!is_object($missing)) { + $missing = new \stdClass(); + } + + return $missing; } /** - * Resolve the {first} segment for an array-like target. - * - * @param mixed $target An array or collection-like object. - * @return string|int|null The first key in the array or collection, or '{first}' if not resolved. + * Retrieve a value from an array using an exact key path. */ - private static function resolveFirst(mixed $target): string|int|null + private static function segmentExact(mixed $array, string $path, mixed $default): mixed { - if (( - is_object($target) - || (is_string($target) && class_exists($target)) - ) && method_exists($target, 'all')) { - $arr = $target->all(); - - return array_key_first($arr); - } elseif (is_array($target)) { - return array_key_first($target); - } - - return '{first}'; + return DotNotationPathOps::segmentExact($array, $path, $default); } /** - * Resolves the {last} segment for an array-like target. - * - * @param mixed $target An array or collection-like object. - * @return string|int|null The last key in the array or collection, or '{last}' if not resolved. + * Sets a value in the target array/object using dot notation. */ - private static function resolveLast(mixed $target): string|int|null + private static function setValue(mixed &$target, string $key, mixed $value, bool $overwrite): void { - if (( - is_object($target) - || (is_string($target) && class_exists($target)) - ) && method_exists($target, 'all')) { - $arr = $target->all(); - - return array_key_last($arr); - } elseif (is_array($target)) { - return array_key_last($target); - } - - return '{last}'; + self::setValueBySegments($target, self::splitPath($key), $value, $overwrite); } /** - * Retrieve a value from an array using an exact key path. - * - * If the key path is not found, the default value is returned. + * Sets a value in the target array using dot-notation segments. * - * @param mixed $array The array to retrieve the value from. - * @param string $path The key path to use for retrieval. - * @param mixed $default The default value to return if the key path is not found. - * @return mixed The retrieved value or default value. + * @param array &$target + * @param array $segments */ - private static function segmentExact(mixed $array, string $path, mixed $default): mixed + private static function setValueArray(array &$target, string $segment, array $segments, mixed $value, bool $overwrite): void { - if (! str_contains($path, '.')) { - return ArraySingle::exists($array, $path) ? $array[$path] : $default; - } - $parts = explode('.', $path); - foreach ($parts as $part) { - if (is_array($array) && ArraySingle::exists($array, $part)) { - $array = $array[$part]; - } else { - return $default; + $segment = self::unescapeSegment($segment); + + if (!empty($segments)) { + if (!ArraySingle::exists($target, $segment)) { + $target[$segment] = []; + } + self::setValueBySegments($target[$segment], $segments, $value, $overwrite); + } else { + if ($overwrite || !ArraySingle::exists($target, $segment)) { + $target[$segment] = $value; } } - - return $array; } /** - * Sets a value in the target array/object using dot notation. - * - * This method sets a value in the target array or object using dot notation. - * It supports wildcard and dot notation for nested arrays or objects. - * If the segment path is not fully defined within the target array, - * it will create nested arrays as necessary. If the `overwrite` flag is true, - * it will replace any existing value at the final segment; otherwise, - * it will only set the value if the property does not already exist. - * - * @param mixed &$target The target array or object to set the value in. - * @param string $key The dot-notation key of the value to set. - * @param mixed $value The value to set. - * @param bool $overwrite If true, overwrite any existing value. + * @param array $segments */ - private static function setValue(mixed &$target, string $key, mixed $value, bool $overwrite): void + private static function setValueBySegments(mixed &$target, array $segments, mixed $value, bool $overwrite): void { - $segments = explode('.', $key); - $first = array_shift($segments); + if ($segments === []) { + return; + } + + $first = self::shiftSegment($segments); + if ($first === null) { + return; + } if ($first === '*') { - static::handleWildcardSet($target, $segments, $value, $overwrite); + self::handleWildcardSet($target, $segments, $value, $overwrite); return; } - if (BaseArrayHelper::accessible($target)) { - static::setValueArray($target, $first, $segments, $value, $overwrite); + if (is_array($target)) { + self::setValueArray($target, $first, $segments, $value, $overwrite); } elseif (is_object($target)) { - static::setValueObject($target, $first, $segments, $value, $overwrite); + self::setValueObject($target, $first, $segments, $value, $overwrite); } else { - static::setValueFallback($target, $first, $segments, $value, $overwrite); - } - } - - /** - * Sets a value in the target array using dot-notation segments. - * - * If the segment path is not fully defined within the target array, - * it will create nested arrays as necessary. If the `overwrite` flag is - * true, it will replace any existing value at the final segment; - * otherwise, it will only set the value if the property does not - * already exist. - * - * @param array &$target The target array to set the value in. - * @param string $segment The current segment of the dot-notation key. - * @param array $segments The remaining segments of the dot-notation key. - * @param mixed $value The value to set. - * @param bool $overwrite If true, overwrite any existing value. - */ - private static function setValueArray(array &$target, string $segment, array $segments, mixed $value, bool $overwrite): void - { - if (! empty($segments)) { - if (! ArraySingle::exists($target, $segment)) { - $target[$segment] = []; - } - static::setValue($target[$segment], implode('.', $segments), $value, $overwrite); - } else { - if ($overwrite || ! ArraySingle::exists($target, $segment)) { - $target[$segment] = $value; - } + self::setValueFallback($target, $first, $segments, $value, $overwrite); } } /** * Sets a value in a target that is not an array or object. * - * This function is called when the target is not an array or object. - * It creates an array and sets the value in the array. - * - * @param mixed &$target The target to set the value in. - * @param string $segment The segment of the dot-notation key. - * @param array $segments The segments of the dot-notation key. - * @param mixed $value The value to set. - * @param bool $overwrite If true, overwrite any existing value. + * @param array $segments */ private static function setValueFallback(mixed &$target, string $segment, array $segments, mixed $value, bool $overwrite): void { + $segment = self::unescapeSegment($segment); $target = []; - if (! empty($segments)) { - static::setValue($target[$segment], implode('.', $segments), $value, $overwrite); + if (!empty($segments)) { + self::setValueBySegments($target[$segment], $segments, $value, $overwrite); } elseif ($overwrite) { $target[$segment] = $value; } @@ -728,117 +216,71 @@ private static function setValueFallback(mixed &$target, string $segment, array /** * Sets a value in an object using dot-notation segments. * - * This function is responsible for setting a value in a given object - * by traversing the object's properties using dot-notation segments. - * If the segment path is not fully defined within the object, it will - * create nested arrays as necessary. If the `overwrite` flag is true, - * it will replace any existing value at the final segment; otherwise, - * it will only set the value if the property does not already exist. - * - * @param object &$target The object to set the value in. - * @param string $segment The current segment of the dot-notation key. - * @param array $segments The remaining segments of the dot-notation key. - * @param mixed $value The value to set. - * @param bool $overwrite If true, overwrite any existing value. + * @param array $segments */ private static function setValueObject(object &$target, string $segment, array $segments, mixed $value, bool $overwrite): void { - if (! empty($segments)) { - if (! isset($target->{$segment})) { + $segment = self::unescapeSegment($segment); + + if (!empty($segments)) { + if (!isset($target->{$segment})) { $target->{$segment} = []; } - static::setValue($target->{$segment}, implode('.', $segments), $value, $overwrite); + self::setValueBySegments($target->{$segment}, $segments, $value, $overwrite); } else { - if ($overwrite || ! isset($target->{$segment})) { + if ($overwrite || !isset($target->{$segment})) { $target->{$segment} = $value; } } } /** - * Traverses the target array/object to retrieve a value using dot notation. - * - * This method is called recursively by the `get` method to traverse the given - * array or object using dot notation. It expects the target array or object, - * the segments of the dot-notation key, and the default value to return if - * the key is not found. - * - * @param mixed $target The array or object to traverse. - * @param array $segments The segments of the dot-notation key. - * @param mixed $default The default value to return if the key is not found. - * @return mixed The retrieved value. + * @param array $segments */ - private static function traverseGet(mixed $target, array $segments, mixed $default): mixed + private static function shiftSegment(array &$segments): ?string { - foreach ($segments as $i => $segment) { - unset($segments[$i]); - - if ($segment === null) { - return $target; - } - - if ($segment === '*') { - return static::traverseWildcard($target, $segments, $default); - } - - $normalized = static::normalizeSegment($segment, $target); - $target = static::accessSegment($target, $normalized, $default); - if ($target === static::value($default)) { - return static::value($default); - } + if ($segments === []) { + return null; } - return $target; + $segment = $segments[0] ?? null; + array_shift($segments); + + return is_string($segment) ? $segment : null; } /** - * Traverse a target array/object using dot-notation with wildcard support. + * Parse a dot path into escaped segments and cache compiled segments. * - * This method handles cases where a wildcard ('*') is present in the dot-notation key. - * It iterates over each element of the target, applying the remaining segments to retrieve - * the specified value. If segments contain another wildcard, the results are collapsed into - * a single array. If the target is not accessible, the default value is returned. - * - * @param mixed $target The array or object to traverse. - * @param array $segments The segments of the dot-notation key, including potential wildcards. - * @param mixed $default The default value to return if the key is not found. - * @return mixed The retrieved value(s) from the target based on the dot-notation key. + * @return array */ - private static function traverseWildcard(mixed $target, array $segments, mixed $default): mixed + private static function splitPath(string $path): array { - $target = ( - is_object($target) - || (is_string($target) && class_exists($target)) - ) && method_exists($target, 'all') ? $target->all() : $target; - - if (! BaseArrayHelper::accessible($target)) { - return static::value($default); - } - - $result = []; - foreach ($target as $item) { - $result[] = static::traverseGet($item, $segments, $default); - } - if (in_array('*', $segments, true)) { - $result = ArrayMulti::collapse($result); - } - - return $result; + return DotNotationPathOps::splitPath($path); } /** - * Unset a property from an object. - * - * This method removes a specified property from an object by using - * PHP's unset function. The property is directly removed from the - * object if it exists. + * Traverses the target array/object to retrieve a value using dot notation. * - * @param object $object The object from which the property should be removed. - * @param string $property The name of the property to unset. + * @param array $segments + */ + private static function traverseGet(mixed $target, array $segments, mixed $default, object $missing): mixed + { + return DotNotationPathOps::traverseGet( + $target, + $segments, + $default, + $missing, + static fn(mixed $value): mixed => self::value($value), + ); + } + + /** + * Convert escaped segment markers into literal key text. */ - private static function unsetProperty(object &$object, string $property): void + private static function unescapeSegment(string $segment): string { - unset($object->{$property}); + return DotNotationPathOps::unescapeSegment($segment); } /** @@ -846,6 +288,6 @@ private static function unsetProperty(object &$object, string $property): void */ private static function value(mixed $val): mixed { - return \isCallable($val) ? $val() : $val; + return $val instanceof \Closure ? $val() : $val; } } diff --git a/src/Array/DotNotationPathOps.php b/src/Array/DotNotationPathOps.php new file mode 100644 index 0000000..a9cfb5a --- /dev/null +++ b/src/Array/DotNotationPathOps.php @@ -0,0 +1,229 @@ + $target[$segment], + is_object($target) && isset($target->{$segment}) => $target->{$segment}, + default => $missing, + }; + } + + /** + * Normalize a dot-notation segment by replacing escaped values and resolving + * special values such as '{first}' and '{last}'. + */ + public static function normalizeSegment(string $segment, mixed $target): int|string + { + return match ($segment) { + '\\*' => '*', + '\\{first}' => '{first}', + '{first}' => self::resolveFirst($target) ?? '{first}', + '\\{last}' => '{last}', + '{last}' => self::resolveLast($target) ?? '{last}', + default => self::unescapeSegment($segment), + }; + } + + /** + * Retrieve a value from an array using an exact key path. + */ + public static function segmentExact(mixed $array, string $path, mixed $default): mixed + { + $parts = self::splitPath($path); + foreach ($parts as $part) { + $resolved = self::unescapeSegment($part); + if (is_array($array) && ArraySingle::exists($array, $resolved)) { + $array = $array[$resolved]; + } else { + return $default; + } + } + + return $array; + } + + /** + * Parse a dot path into escaped segments and cache compiled segments. + * + * @return array + */ + public static function splitPath(string $path): array + { + /** @var array> $cache */ + static $cache = []; + /** @var array $cacheKeys */ + static $cacheKeys = []; + $maxEntries = 1024; + + if (isset($cache[$path])) { + return $cache[$path]; + } + + $segments = []; + $current = ''; + $escaped = false; + + $length = strlen($path); + for ($i = 0; $i < $length; $i++) { + $char = $path[$i]; + + if ($escaped) { + $current .= '\\' . $char; + $escaped = false; + + continue; + } + + if ($char === '\\') { + $escaped = true; + + continue; + } + + if ($char === '.') { + $segments[] = $current; + $current = ''; + + continue; + } + + $current .= $char; + } + + if ($escaped) { + $current .= '\\\\'; + } + + $segments[] = $current; + + $cache[$path] = $segments; + $cacheKeys[] = $path; + if (count($cacheKeys) > $maxEntries) { + $evicted = array_shift($cacheKeys); + unset($cache[$evicted]); + } + + return $segments; + } + + /** + * Traverse a target using dot-notation segments. + * + * @param array $segments + * @param callable(mixed): mixed $defaultResolver + */ + public static function traverseGet(mixed $target, array $segments, mixed $default, object $missing, callable $defaultResolver): mixed + { + foreach ($segments as $index => $segment) { + unset($segments[$index]); + + if ($segment === '*') { + return self::traverseWildcard($target, $segments, $default, $missing, $defaultResolver); + } + + $normalized = self::normalizeSegment($segment, $target); + $target = self::accessSegment($target, $normalized, $missing); + if ($target === $missing) { + return $missing; + } + } + + return $target; + } + + /** + * Convert escaped segment markers into literal key text. + */ + public static function unescapeSegment(string $segment): string + { + $unescaped = str_replace( + ['\\.', '\\\\'], + ['.', '\\'], + $segment, + ); + + return str_replace( + ['\\*', '\\{first}', '\\{last}'], + ['*', '{first}', '{last}'], + $unescaped, + ); + } + + /** + * Resolve the {first} segment for an array-like target. + */ + private static function resolveFirst(mixed $target): string|int|null + { + if (is_object($target) && method_exists($target, 'all')) { + $arr = $target->all(); + if (!is_array($arr)) { + return null; + } + + return array_key_first($arr); + } + + if (is_array($target)) { + return array_key_first($target); + } + + return '{first}'; + } + + /** + * Resolves the {last} segment for an array-like target. + */ + private static function resolveLast(mixed $target): string|int|null + { + if (is_object($target) && method_exists($target, 'all')) { + $arr = $target->all(); + if (!is_array($arr)) { + return null; + } + + return array_key_last($arr); + } + + if (is_array($target)) { + return array_key_last($target); + } + + return '{last}'; + } + + /** + * Traverse a target array/object using dot-notation with wildcard support. + * + * @param array $segments + * @param callable(mixed): mixed $defaultResolver + */ + private static function traverseWildcard(mixed $target, array $segments, mixed $default, object $missing, callable $defaultResolver): mixed + { + $target = is_object($target) && method_exists($target, 'all') ? $target->all() : $target; + + if (!is_array($target)) { + return $missing; + } + + $result = []; + foreach ($target as $item) { + $resolved = self::traverseGet($item, $segments, $default, $missing, $defaultResolver); + $result[] = $resolved === $missing ? $defaultResolver($default) : $resolved; + } + if (in_array('*', $segments, true)) { + $result = ArrayMulti::collapse($result); + } + + return $result; + } +} diff --git a/src/ArrayKit.php b/src/ArrayKit.php index 885cd36..d182ca8 100644 --- a/src/ArrayKit.php +++ b/src/ArrayKit.php @@ -38,6 +38,9 @@ public static function collection(mixed $data = []): Collection return Collection::make($data); } + /** + * @param array $items + */ public static function config(array $items = []): Config { $config = new Config(); @@ -63,6 +66,9 @@ public static function hookedCollection(mixed $data = []): HookedCollection return HookedCollection::make($data); } + /** + * @param array $items + */ public static function lazyConfig(string $directory, string $extension = 'php', array $items = []): LazyFileConfig { return new LazyFileConfig($directory, $extension, $items); diff --git a/src/Collection/BaseCollectionTrait.php b/src/Collection/BaseCollectionTrait.php index 9a3bfb8..11436a1 100644 --- a/src/Collection/BaseCollectionTrait.php +++ b/src/Collection/BaseCollectionTrait.php @@ -14,6 +14,8 @@ trait BaseCollectionTrait { /** * Holds the underlying array data for the collection. + * + * @var array */ protected array $data = []; @@ -22,7 +24,7 @@ trait BaseCollectionTrait /** * Constructor. Initializes the collection with the given array data. * - * @param array $data The initial data for the collection. + * @param array $data The initial data for the collection. */ public function __construct(array $data = []) { @@ -39,7 +41,7 @@ public function __construct(array $data = []) * method does not exist. * * @param string $method The name of the method being called. - * @param array $arguments The arguments to pass to the method. + * @param array $arguments The arguments to pass to the method. * @return mixed The result of the method call. * @throws BadMethodCallException If the method does not exist. */ @@ -49,6 +51,7 @@ public function __call(string $method, array $arguments): mixed if (method_exists($pipeline, $method)) { return $pipeline->$method(...$arguments); } + throw new BadMethodCallException("Method $method does not exist in " . static::class); } @@ -77,21 +80,19 @@ public function __get(string $key): mixed return $this->offsetGet($key); } - /** * Invokes the collection and returns the underlying array data. * * When the collection is invoked as a function (e.g. `$collection()`), * the underlying array data is returned directly. * - * @return array The array data of this collection. + * @return array The array data of this collection. */ public function __invoke(): array { return $this->data; } - /** * Magic isset to check for existence of an item via property access: isset($collection->key) * @@ -106,7 +107,6 @@ public function __isset(string $key): bool return $this->offsetExists($key); } - /** * Magic setter to set an item via property access: $collection->key = $value * @@ -129,7 +129,6 @@ public function __toString(): string return $this->toJson(); } - /** * Magic unset to remove an item via property access: unset($collection->key) * @@ -164,7 +163,7 @@ public static function from(mixed $data): static * * If the given data is not an array, it will be converted to an array. * - * @param mixed $data The data to initialize the collection with. + * @param mixed $data The data to initialize the collection with. */ public static function make(mixed $data): static { @@ -174,13 +173,12 @@ public static function make(mixed $data): static return $instance; } - /** * Returns the entire array of items in this collection. * * This is an alias for the `items()` method. * - * @return array The entire array of items in this collection. + * @return array The entire array of items in this collection. */ public function all(): array { @@ -195,6 +193,14 @@ public function clear(): void $this->data = []; } + /** + * Create a shallow copy of the current collection. + */ + public function copy(): static + { + return new static($this->data); + } + /* |-------------------------------------------------------------------------- | Countable Interface @@ -223,7 +229,6 @@ public function current(): mixed return current($this->data); } - /** * Retrieve an item from the collection by key or keys. * @@ -232,7 +237,7 @@ public function current(): mixed * - If an array of keys is provided, all values are returned in an array. * - If a single key is provided, the value is returned directly. * - * @param string|array $keys The key(s) to retrieve. + * @param string|array $keys The key(s) to retrieve. * @return mixed The retrieved value(s). */ public function get(string|array $keys): mixed @@ -240,7 +245,6 @@ public function get(string|array $keys): mixed return DotNotation::get($this->data, $keys); } - /** * Normalizes the given items to an array. * @@ -250,19 +254,18 @@ public function get(string|array $keys): mixed * Otherwise, it will cast the $items to an array. * * @param mixed $items The items to normalize. - * @return array The normalized items. + * @return array The normalized items. */ public function getArrayableItems(mixed $items): array { return match (true) { $items instanceof self => $items->items(), - $items instanceof JsonSerializable => $items->jsonSerialize(), + $items instanceof JsonSerializable => is_array($items->jsonSerialize()) ? $items->jsonSerialize() : (array) $items->jsonSerialize(), $items instanceof Traversable => iterator_to_array($items), default => (array) $items, }; } - /* |-------------------------------------------------------------------------- | Iterator Interface @@ -276,18 +279,17 @@ public function getArrayableItems(mixed $items): array * to iterate over the collection's data. It is part of the IteratorAggregate * interface, allowing for external iteration of the collection. * - * @return Traversable An iterator for the collection's data. + * @return Traversable An iterator for the collection's data. */ public function getIterator(): Traversable { return new ArrayIterator($this->data); } - /** * Determine if the given key or keys exist in the collection. * - * @param string|array $keys The key(s) to check for existence. + * @param string|array $keys The key(s) to check for existence. * @return bool True if all the given keys exist in the collection, false otherwise. */ public function has(string|array $keys): bool @@ -295,7 +297,6 @@ public function has(string|array $keys): bool return DotNotation::has($this->data, $keys); } - /** * Check if at least one of the given keys exists in the collection. * @@ -303,7 +304,7 @@ public function has(string|array $keys): bool * within the collection's data. It supports checking a single key * or an array of keys. * - * @param string|array $keys The key(s) to check for existence. + * @param string|array $keys The key(s) to check for existence. * @return bool True if at least one key exists, false otherwise. */ public function hasAny(string|array $keys): bool @@ -311,6 +312,14 @@ public function hasAny(string|array $keys): bool return DotNotation::hasAny($this->data, $keys); } + /** + * Return an immutable-style copy for functional pipelines. + */ + public function immutable(): static + { + return $this->copy(); + } + /** * Determine if the collection is empty. */ @@ -321,6 +330,8 @@ public function isEmpty(): bool /** * Return the raw array of items in this collection. + * + * @return array */ public function items(): array { @@ -340,7 +351,7 @@ public function items(): array * to an array representation if it implements the JsonSerializable interface. * Non-serializable items are returned as-is. * - * @return array The array representation of the collection, ready for JSON serialization. + * @return array The array representation of the collection, ready for JSON serialization. */ public function jsonSerialize(): array { @@ -365,6 +376,8 @@ public function key(): string|int|null /** * Return an array of all the keys in the collection. + * + * @return array */ public function keys(): array { @@ -417,6 +430,7 @@ public function offsetExists(mixed $offset): bool if (!is_int($offset) && !is_string($offset)) { return false; } + return array_key_exists($offset, $this->data); } @@ -435,6 +449,11 @@ public function offsetGet(mixed $offset): mixed if (is_string($offset) && str_contains($offset, '.')) { return DotNotation::get($this->data, $offset); } + + if (!is_string($offset) && !is_int($offset)) { + return null; + } + return $this->data[$offset] ?? null; } @@ -450,16 +469,22 @@ public function offsetGet(mixed $offset): mixed */ public function offsetSet(mixed $offset, mixed $value): void { - match (true) { - $offset === null => $this->data[] = $value, + if ($offset === null) { + $this->data[] = $value; - is_string($offset) && str_contains($offset, '.') - => DotNotation::set($this->data, $offset, $value), + return; + } - default => $this->data[$offset] = $value, - }; - } + if (is_string($offset) && str_contains($offset, '.')) { + DotNotation::set($this->data, $offset, $value); + + return; + } + if (is_string($offset) || is_int($offset)) { + $this->data[$offset] = $value; + } + } /** * Remove an item from the collection by key. @@ -475,9 +500,13 @@ public function offsetUnset(mixed $offset): void { if (is_string($offset) && str_contains($offset, '.')) { DotNotation::forget($this->data, $offset); + return; } - unset($this->data[$offset]); + + if (is_string($offset) || is_int($offset)) { + unset($this->data[$offset]); + } } /** @@ -503,7 +532,6 @@ public function rewind(): void reset($this->data); } - /** * Set one or multiple items in the collection using dot notation. * @@ -511,7 +539,7 @@ public function rewind(): void * If an array of key-value pairs is provided, each value is set. * If a single key is provided, the value is set directly. * - * @param array|string|null $keys The key(s) to set. + * @param array|string|null $keys The key(s) to set. * @param mixed $value The value to set. * @return bool True on success. */ @@ -522,6 +550,8 @@ public function set(array|string|null $keys = null, mixed $value = null): bool /** * Get the collection of items as a plain array. + * + * @return array */ public function toArray(): array { @@ -531,7 +561,7 @@ public function toArray(): array /** * Get the collection of items as a JSON string. * - * @param int $options JSON encoding options + * @param int $options JSON encoding options */ public function toJson(int $options = 0): string { diff --git a/src/Collection/Collection.php b/src/Collection/Collection.php index 7086db2..f225979 100644 --- a/src/Collection/Collection.php +++ b/src/Collection/Collection.php @@ -6,17 +6,21 @@ use ArrayAccess; use Countable; -use Iterator; +use IteratorAggregate; use JsonSerializable; /** * Class BucketCollection * * A simple array-based collection that implements common - * interfaces (ArrayAccess, Iterator, Countable, JsonSerializable). + * interfaces (ArrayAccess, IteratorAggregate, Countable, JsonSerializable). * Inherits most of its behavior from BaseCollectionTrait. + * + * @phpstan-consistent-constructor + * @implements ArrayAccess + * @implements IteratorAggregate */ -class Collection implements ArrayAccess, Countable, Iterator, JsonSerializable +class Collection implements ArrayAccess, Countable, IteratorAggregate, JsonSerializable { use BaseCollectionTrait; } diff --git a/src/Collection/HookedCollection.php b/src/Collection/HookedCollection.php index 0324ec4..0304bf6 100644 --- a/src/Collection/HookedCollection.php +++ b/src/Collection/HookedCollection.php @@ -35,6 +35,10 @@ public function offsetGet(mixed $offset): mixed ? DotNotation::get($this->data, $offset) : parent::offsetGet($offset); + if (!is_string($offset) && !is_int($offset)) { + return $value; + } + return $this->processValue($offset, $value, 'get'); } @@ -44,15 +48,22 @@ public function offsetGet(mixed $offset): mixed * Applies any "on set" hooks associated with that offset. * * @param mixed $offset The array key - * @param mixed $value The value to set + * @param mixed $value The value to set */ #[\Override] public function offsetSet(mixed $offset, mixed $value): void { + if (!is_string($offset) && !is_int($offset)) { + parent::offsetSet($offset, $value); + + return; + } + $processed = $this->processValue($offset, $value, 'set'); if (is_string($offset) && str_contains($offset, '.')) { DotNotation::set($this->data, $offset, $processed); + return; } diff --git a/src/Collection/Pipeline.php b/src/Collection/Pipeline.php index f314830..492bcd2 100644 --- a/src/Collection/Pipeline.php +++ b/src/Collection/Pipeline.php @@ -12,6 +12,8 @@ class Pipeline { /** * Construct with an initial array. + * + * @param array $working */ public function __construct( protected array &$working, @@ -33,6 +35,7 @@ public function any(callable $callback): bool public function between(string $key, float|int $from, float|int $to): Collection { $this->working = ArrayMulti::between($this->working, $key, $from, $to); + return $this->collection; } @@ -42,6 +45,7 @@ public function between(string $key, float|int $from, float|int $to): Collection public function chunk(int $size, bool $preserveKeys = false): Collection { $this->working = ArraySingle::chunk($this->working, $size, $preserveKeys); + return $this->collection; } @@ -51,18 +55,52 @@ public function chunk(int $size, bool $preserveKeys = false): Collection public function collapse(): Collection { $this->working = ArrayMulti::collapse($this->working); + return $this->collection; } /** * Combine the current array with a second array of values, using ArraySingle::combine. * (We treat $this->working as the *keys*, user passes an array of values.) + * + * @param array $values */ public function combine(array $values): Collection { $combined = ArraySingle::combine($this->working, $values); // Replacing the entire array with the combined result $this->working = $combined; + + return $this->collection; + } + + /** + * Count items grouped by value/callback buckets. + * + * @return array + */ + public function countBy(callable|string|null $groupBy = null): array + { + if ($groupBy === null) { + return ArraySingle::countBy($this->working); + } + + if (is_string($groupBy)) { + return ArrayMulti::countBy($this->working, $groupBy); + } + + return ArraySingle::countBy($this->working, $groupBy); + } + + /** + * Keep values from current array that do not exist in the provided array. + * + * @param array $values + */ + public function diff(array $values, bool $strict = false): Collection + { + $this->working = ArraySingle::diff($this->working, $values, $strict); + return $this->collection; } @@ -77,13 +115,18 @@ public function duplicates(): Collection // This means our collection now becomes an array of those duplicated values. // Possibly you might want to keep them in a "counts" structure, but let's do direct. $this->working = $dupes; + return $this->collection; } /** Remove keys (inverse of only) */ + /** + * @param array|string $keys + */ public function except(array|string $keys): Collection { $this->working = ArraySingle::except($this->working, $keys); + return $this->collection; } @@ -94,6 +137,7 @@ public function filter(callable $callback): Collection { // We use "where(...)" or direct array_filter: $this->working = ArraySingle::where($this->working, $callback); + return $this->collection; } @@ -106,6 +150,14 @@ public function first(?callable $callback = null, mixed $default = null): mixed return ArrayMulti::first($this->working, $callback, $default); } + /** + * Return the first row where the key comparison matches. + */ + public function firstWhere(string $key, mixed $operator = null, mixed $value = null, mixed $default = null): mixed + { + return ArrayMulti::firstWhere($this->working, $key, $operator, $value, $default); + } + /* |-------------------------------------------------------------------------- | ArrayMulti-based chainable methods (Multi-Dimensional usage) @@ -118,6 +170,7 @@ public function first(?callable $callback = null, mixed $default = null): mixed public function flatten(float|int $depth = \INF): Collection { $this->working = ArrayMulti::flatten($this->working, $depth); + return $this->collection; } @@ -127,6 +180,7 @@ public function flatten(float|int $depth = \INF): Collection public function flattenByKey(): Collection { $this->working = ArrayMulti::flattenByKey($this->working); + return $this->collection; } @@ -136,6 +190,29 @@ public function flattenByKey(): Collection public function groupBy(string|callable $groupBy, bool $preserveKeys = false): Collection { $this->working = ArrayMulti::groupBy($this->working, $groupBy, $preserveKeys); + + return $this->collection; + } + + /** + * Alias of keyBy(). + */ + public function indexBy(string|callable $indexBy): Collection + { + $this->working = ArrayMulti::indexBy($this->working, $indexBy); + + return $this->collection; + } + + /** + * Keep values that exist in both arrays. + * + * @param array $values + */ + public function intersect(array $values, bool $strict = false): Collection + { + $this->working = ArraySingle::intersect($this->working, $values, $strict); + return $this->collection; } @@ -153,6 +230,16 @@ public function isMultiDimensional(): bool return BaseArrayHelper::isMultiDimensional($this->working); } + /** + * Key rows by a derived key (last write wins on duplicates). + */ + public function keyBy(string|callable $keyBy): Collection + { + $this->working = ArrayMulti::keyBy($this->working, $keyBy); + + return $this->collection; + } + /** * Return the last item in a 2D array, or single-dim array, depending on usage. */ @@ -167,16 +254,74 @@ public function last(?callable $callback = null, mixed $default = null): mixed public function map(callable $callback): Collection { $this->working = ArraySingle::map($this->working, $callback); + return $this->collection; } + /** + * Transform items and return a key/value map from callback results. + */ + public function mapWithKeys(callable $callback): Collection + { + $this->working = ArraySingle::mapWithKeys($this->working, $callback); + + return $this->collection; + } + + /** + * Return the maximum numeric value. + */ + public function max(string|callable|null $keyOrCallback = null): float|int|null + { + return $this->numericAggregate($keyOrCallback, pickMax: true); + } + + /** + * Return the item/row with the maximum callback score. + */ + public function maxBy(string|callable $keyOrCallback): mixed + { + return $this->selectExtremeBy($keyOrCallback, pickMax: true); + } + /** Return the statistical median – TERMINATES chain (scalar) */ public function median(): float|int { return ArraySingle::median($this->working); } + /** + * Recursively merge while keeping scalar collisions distinct (override wins). + * + * @param array $overlay + */ + public function mergeRecursiveDistinct(array $overlay): Collection + { + $this->working = ArrayMulti::mergeRecursiveDistinct($this->working, $overlay); + + return $this->collection; + } + + /** + * Return the minimum numeric value. + */ + public function min(string|callable|null $keyOrCallback = null): float|int|null + { + return $this->numericAggregate($keyOrCallback, pickMax: false); + } + + /** + * Return the item/row with the minimum callback score. + */ + public function minBy(string|callable $keyOrCallback): mixed + { + return $this->selectExtremeBy($keyOrCallback, pickMax: false); + } + /** Return the statistical mode(s) – TERMINATES chain (array) */ + /** + * @return array + */ public function mode(): array { return ArraySingle::mode($this->working); @@ -188,6 +333,7 @@ public function mode(): array public function nth(int $step, int $offset = 0): Collection { $this->working = ArraySingle::nth($this->working, $step, $offset); + return $this->collection; } @@ -200,10 +346,25 @@ public function nth(int $step, int $offset = 0): Collection /** * Keep only certain keys in the array, using ArraySingle::only. * (Typically relevant if the array is associative 1D.) + * + * @param array|string $keys */ public function only(array|string $keys): Collection { $this->working = ArraySingle::only($this->working, $keys); + + return $this->collection; + } + + /** + * Overlay another array on top of current data (distinct recursive merge). + * + * @param array $overlay + */ + public function overlay(array $overlay): Collection + { + $this->working = ArrayMulti::overlay($this->working, $overlay); + return $this->collection; } @@ -213,6 +374,7 @@ public function only(array|string $keys): Collection public function paginate(int $page, int $perPage): Collection { $this->working = ArraySingle::paginate($this->working, $page, $perPage); + return $this->collection; } @@ -222,17 +384,19 @@ public function paginate(int $page, int $perPage): Collection public function partition(callable $callback): Collection { $this->working = ArraySingle::partition($this->working, $callback); + return $this->collection; } /** * Pipe the working array through a callback, replacing it with whatever you return. * - * @param callable $callback fn(array $working): array + * @param callable(array): array $callback */ public function pipe(callable $callback): Collection { $this->working = $callback($this->working); + return $this->collection; } @@ -240,6 +404,7 @@ public function pipe(callable $callback): Collection public function pluck(string $column, ?string $indexBy = null): Collection { $this->working = ArrayMulti::pluck($this->working, $column, $indexBy); + return $this->collection; } @@ -257,15 +422,51 @@ public function reduce(callable $callback, mixed $initial = null): mixed public function reject(mixed $callback = true): Collection { $this->working = ArraySingle::reject($this->working, $callback); + + return $this->collection; + } + + /** + * Rename keys using a map or callback. + * + * @param array|callable $mapper + */ + public function rekey(array|callable $mapper): Collection + { + $this->working = ArraySingle::rekey($this->working, $mapper); + return $this->collection; } + /** + * Recursively replace values. + * + * @param array $replacements + */ + public function replaceRecursive(array $replacements): Collection + { + $this->working = ArrayMulti::replaceRecursive($this->working, $replacements); + + return $this->collection; + } + + /** + * Determine if the working set has the same values as another array. + * + * @param array $values + */ + public function same(array $values, bool $strict = false): bool + { + return ArraySingle::same($this->working, $values, $strict); + } + /** * Shuffle the array in place, from ArraySingle::shuffle or BaseArrayHelper logic. */ public function shuffle(?int $seed = null): Collection { $this->working = ArraySingle::shuffle($this->working, $seed); + return $this->collection; } @@ -275,6 +476,7 @@ public function shuffle(?int $seed = null): Collection public function skip(int $count): Collection { $this->working = ArraySingle::skip($this->working, $count); + return $this->collection; } @@ -284,6 +486,7 @@ public function skip(int $count): Collection public function skipUntil(callable $callback): Collection { $this->working = ArraySingle::skipUntil($this->working, $callback); + return $this->collection; } @@ -293,6 +496,7 @@ public function skipUntil(callable $callback): Collection public function skipWhile(callable $callback): Collection { $this->working = ArraySingle::skipWhile($this->working, $callback); + return $this->collection; } @@ -302,6 +506,7 @@ public function skipWhile(callable $callback): Collection public function slice(int $offset, ?int $length = null): Collection { $this->working = ArraySingle::slice($this->working, $offset, $length); + return $this->collection; } @@ -311,6 +516,7 @@ public function slice(int $offset, ?int $length = null): Collection public function sortBy(string|callable $by, bool $desc = false, int $options = SORT_REGULAR): Collection { $this->working = ArrayMulti::sortBy($this->working, $by, $desc, $options); + return $this->collection; } @@ -320,6 +526,7 @@ public function sortBy(string|callable $by, bool $desc = false, int $options = S public function sortRecursive(int $options = SORT_REGULAR, bool $descending = false): Collection { $this->working = ArrayMulti::sortRecursive($this->working, $options, $descending); + return $this->collection; } @@ -338,14 +545,27 @@ public function sum(?callable $callback = null): float|int return ArraySingle::sum($this->working, $callback); } + /** + * Keep values that exist in either array but not both. + * + * @param array $values + */ + public function symmetricDiff(array $values, bool $strict = false): Collection + { + $this->working = ArraySingle::symmetricDiff($this->working, $values, $strict); + + return $this->collection; + } + /** * Tap into the current working array for side-effects (debug/log), then continue. * - * @param callable $callback fn(array $working): void + * @param callable $callback fn(array $working): void */ public function tap(callable $callback): Collection { $callback($this->working); + return $this->collection; } @@ -353,6 +573,7 @@ public function tap(callable $callback): Collection public function transpose(): Collection { $this->working = ArrayMulti::transpose($this->working); + return $this->collection; } @@ -362,6 +583,7 @@ public function transpose(): Collection public function unique(bool $strict = false): Collection { $this->working = ArraySingle::unique($this->working, $strict); + return $this->collection; } @@ -370,7 +592,7 @@ public function unique(bool $strict = false): Collection */ public function unless(bool $condition, callable $callback, ?callable $default = null): Collection { - return $this->when(! $condition, $callback, $default); + return $this->when(!$condition, $callback, $default); } /** @@ -378,18 +600,27 @@ public function unless(bool $condition, callable $callback, ?callable $default = */ public function unWrap(): Collection { - // Might produce a non-array, so up to you if you want to store that as $working... $unwrapped = BaseArrayHelper::unWrap($this->working); - // If $unwrapped is not array, we store it as a single-element array to keep chain consistent $this->working = is_array($unwrapped) ? $unwrapped : [$unwrapped]; + + return $this->collection; + } + + /** + * Reindex the working set numerically. + */ + public function values(): Collection + { + $this->working = ArraySingle::values($this->working); + return $this->collection; } /** * Conditionally apply one of two callbacks based on $condition. * - * @param callable $callback fn(array $working): array - * @param callable|null $default fn(array $working): array + * @param callable(array): array $callback + * @param callable(array): array|null $default */ public function when(bool $condition, callable $callback, ?callable $default = null): Collection { @@ -398,6 +629,7 @@ public function when(bool $condition, callable $callback, ?callable $default = n } elseif ($default) { $this->working = $default($this->working); } + return $this->collection; } @@ -407,6 +639,7 @@ public function when(bool $condition, callable $callback, ?callable $default = n public function where(string $key, mixed $operator = null, mixed $value = null): Collection { $this->working = ArrayMulti::where($this->working, $key, $operator, $value); + return $this->collection; } @@ -415,25 +648,33 @@ public function where(string $key, mixed $operator = null, mixed $value = null): */ public function whereCallback(?callable $callback = null, mixed $default = null): Collection { - $this->working = ArrayMulti::whereCallback($this->working, $callback, $default); + $result = ArrayMulti::whereCallback($this->working, $callback, $default); + $this->working = is_array($result) ? $result : [$result]; + return $this->collection; } /** * Filter rows where "column" matches one of the given values. + * + * @param array $values */ public function whereIn(string $key, array $values, bool $strict = false): Collection { $this->working = ArrayMulti::whereIn($this->working, $key, $values, $strict); + return $this->collection; } /** * Filter rows where "column" is not in the given values. + * + * @param array $values */ public function whereNotIn(string $key, array $values, bool $strict = false): Collection { $this->working = ArrayMulti::whereNotIn($this->working, $key, $values, $strict); + return $this->collection; } @@ -443,6 +684,7 @@ public function whereNotIn(string $key, array $values, bool $strict = false): Co public function whereNotNull(string $key): Collection { $this->working = ArrayMulti::whereNotNull($this->working, $key); + return $this->collection; } @@ -452,6 +694,7 @@ public function whereNotNull(string $key): Collection public function whereNull(string $key): Collection { $this->working = ArrayMulti::whereNull($this->working, $key); + return $this->collection; } @@ -461,7 +704,40 @@ public function whereNull(string $key): Collection public function wrap(): Collection { $this->working = BaseArrayHelper::wrap($this->working); + return $this->collection; } + private function numericAggregate(string|callable|null $keyOrCallback, bool $pickMax): float|int|null + { + if ($keyOrCallback === null) { + return $pickMax ? ArraySingle::max($this->working) : ArraySingle::min($this->working); + } + + if (is_string($keyOrCallback)) { + return $pickMax + ? ArrayMulti::max($this->working, $keyOrCallback) + : ArrayMulti::min($this->working, $keyOrCallback); + } + + $mapped = ArraySingle::map( + $this->working, + static fn(mixed $value, int|string $key): mixed => $keyOrCallback($value, $key), + ); + + return $pickMax ? ArraySingle::max($mapped) : ArraySingle::min($mapped); + } + + private function selectExtremeBy(string|callable $keyOrCallback, bool $pickMax): mixed + { + if (is_string($keyOrCallback)) { + return $pickMax + ? ArrayMulti::maxBy($this->working, $keyOrCallback) + : ArrayMulti::minBy($this->working, $keyOrCallback); + } + + return $pickMax + ? ArraySingle::maxBy($this->working, $keyOrCallback) + : ArraySingle::minBy($this->working, $keyOrCallback); + } } diff --git a/src/Config/BaseConfigTrait.php b/src/Config/BaseConfigTrait.php index 6636192..cc57447 100644 --- a/src/Config/BaseConfigTrait.php +++ b/src/Config/BaseConfigTrait.php @@ -5,18 +5,20 @@ namespace Infocyph\ArrayKit\Config; use Infocyph\ArrayKit\Array\DotNotation; +use OutOfBoundsException; +use UnexpectedValueException; trait BaseConfigTrait { /** - * @var array Internal storage for config items + * @var array Internal storage for config items */ protected array $items = []; /** * Retrieve all configuration items. * - * @return array The entire configuration array + * @return array The entire configuration array */ public function all(): array { @@ -26,35 +28,45 @@ public function all(): array /** * Append a value to a configuration array at the specified key. * - * @param string $key The dot-notation key referencing an array - * @param mixed $value The value to append + * @param string $key The dot-notation key referencing an array + * @param mixed $value The value to append * @return bool True on success */ public function append(string $key, mixed $value): bool { - $array = $this->get($key, []); + $array = $this->get($key, []); + if (!is_array($array)) { + $array = []; + } + $array[] = $value; + return $this->set($key, $array); } /** * "Fill" config data where it's missing, i.e. DotNotation's fill logic. * - * @param string|array $key Dot-notation key or multiple [key => value] - * @param mixed|null $value The value to set if missing + * @param string|array $key Dot-notation key or multiple [key => value] + * @param mixed|null $value The value to set if missing */ public function fill(string|array $key, mixed $value = null): bool { DotNotation::fill($this->items, $key, $value); + return true; } /** * Remove/unset a key (or keys) from configuration using dot notation + wildcard expansions. */ + /** + * @param string|int|array $key + */ public function forget(string|int|array $key): bool { DotNotation::forget($this->items, $key); + return true; } @@ -62,8 +74,8 @@ public function forget(string|int|array $key): bool * Get one or multiple items from the configuration. * Includes wildcard support (e.g. '*'), {first}, {last}, etc. * - * @param string|int|array|null $key Dot-notation key(s) or null for entire config - * @param mixed|null $default Default value if key not found + * @param string|int|array|null $key Dot-notation key(s) or null for entire config + * @param mixed|null $default Default value if key not found * @return mixed The value(s) found or default */ public function get(string|int|array|null $key = null, mixed $default = null): mixed @@ -71,10 +83,27 @@ public function get(string|int|array|null $key = null, mixed $default = null): m return DotNotation::get($this->items, $key, $default); } + /** + * Get a required configuration value or throw when missing. + * + * @param string|int|array|null $key + */ + public function getOrFail(string|int|array|null $key): mixed + { + $missing = new \stdClass(); + $value = $this->get($key, $missing); + + if ($value === $missing) { + throw new OutOfBoundsException('Required config key is missing.'); + } + + return $value; + } + /** * Check if one or multiple keys exist in the configuration (no wildcard). * - * @param string|array $keys Dot-notation key(s) + * @param string|array $keys Dot-notation key(s) * @return bool True if the key(s) exist */ public function has(string|array $keys): bool @@ -85,7 +114,7 @@ public function has(string|array $keys): bool /** * Check if *any* of the given keys exist (no wildcard). * - * @param string|array $keys Dot-notation key(s) + * @param string|array $keys Dot-notation key(s) * @return bool True if at least one key exists */ public function hasAny(string|array $keys): bool @@ -96,15 +125,17 @@ public function hasAny(string|array $keys): bool /** * Load configuration directly from an array resource. * - * @param array $resource The array containing config items + * @param array $resource The array containing config items * @return bool True if loaded successfully, false if already loaded */ public function loadArray(array $resource): bool { if (count($this->items) === 0) { $this->items = $resource; + return true; } + return false; } @@ -116,10 +147,17 @@ public function loadArray(array $resource): bool */ public function loadFile(string $path): bool { - if (count($this->items) === 0 && file_exists($path)) { - $this->items = include $path; + if (count($this->items) === 0 && is_file($path) && is_readable($path)) { + $loaded = include $path; + if (!is_array($loaded)) { + throw new UnexpectedValueException("Config file [{$path}] must return an array."); + } + + $this->items = $loaded; + return true; } + return false; } @@ -127,26 +165,66 @@ public function loadFile(string $path): bool * Prepend a value to a configuration array at the specified key. * (No direct wildcard usage, though underlying DotNotation can handle it if needed.) * - * @param string $key The dot-notation key referencing an array - * @param mixed $value The value to prepend + * @param string $key The dot-notation key referencing an array + * @param mixed $value The value to prepend * @return bool True on success */ public function prepend(string $key, mixed $value): bool { $array = $this->get($key, []); + if (!is_array($array)) { + $array = []; + } + array_unshift($array, $value); + return $this->set($key, $array); } + /** + * Reload configuration from an array or file path, replacing existing data. + * + * @param array|string $source + */ + public function reload(array|string $source): bool + { + if (is_array($source)) { + return $this->replace($source); + } + + if (!is_file($source) || !is_readable($source)) { + return false; + } + + $loaded = include $source; + if (!is_array($loaded)) { + throw new UnexpectedValueException("Config file [{$source}] must return an array."); + } + + return $this->replace($loaded); + } + + /** + * Replace the entire configuration storage with a new array. + * + * @param array $items + */ + public function replace(array $items): bool + { + $this->items = $items; + + return true; + } + /** * Set a configuration value by dot-notation key (wildcard support), * optionally controlling overwrite vs. fill-like behavior. * * If no key is provided, replaces the entire config array with $value. * - * @param string|array|null $key Dot-notation key or [key => value] array - * @param mixed|null $value The value to set - * @param bool $overwrite Overwrite existing? Default true. + * @param string|array|null $key Dot-notation key or [key => value] array + * @param mixed|null $value The value to set + * @param bool $overwrite Overwrite existing? Default true. * @return bool True on success */ public function set(string|array|null $key = null, mixed $value = null, bool $overwrite = true): bool diff --git a/src/Config/Config.php b/src/Config/Config.php index c4a894f..c483e2b 100644 --- a/src/Config/Config.php +++ b/src/Config/Config.php @@ -22,6 +22,8 @@ class Config /** * Hook-aware variant of fill(). + * + * @param string|array $key */ public function fillWithHooks(string|array $key, mixed $value = null): bool { @@ -41,12 +43,18 @@ public function fillWithHooks(string|array $key, mixed $value = null): bool /** * Hook-aware variant of get(). + * + * @param int|string|array|null $key */ public function getWithHooks(int|string|array|null $key = null, mixed $default = null): mixed { $value = $this->get($key, $default); if (is_array($key)) { + if (!is_array($value)) { + return $value; + } + foreach ($value as $path => $entry) { $value[$path] = $this->processValue($path, $entry, 'get'); } @@ -54,11 +62,17 @@ public function getWithHooks(int|string|array|null $key = null, mixed $default = return $value; } + if ($key === null) { + return $value; + } + return $this->processValue($key, $value, 'get'); } /** * Hook-aware variant of set(). + * + * @param string|array|null $key */ public function setWithHooks(string|array|null $key = null, mixed $value = null, bool $overwrite = true): bool { @@ -71,6 +85,10 @@ public function setWithHooks(string|array|null $key = null, mixed $value = null, return $this->set($processed, null, $overwrite); } + if ($key === null) { + return $this->set($key, $value, $overwrite); + } + $processedValue = $this->processValue($key, $value, 'set'); return $this->set($key, $processedValue, $overwrite); diff --git a/src/Config/LazyFileConfig.php b/src/Config/LazyFileConfig.php index 37ee7bd..a97367e 100644 --- a/src/Config/LazyFileConfig.php +++ b/src/Config/LazyFileConfig.php @@ -11,8 +11,14 @@ class LazyFileConfig extends Config { + /** + * @var array + */ protected array $loadedNamespaces = []; + /** + * @param array $items + */ public function __construct( protected string $directory, protected string $extension = 'php', @@ -24,12 +30,18 @@ public function __construct( } #[\Override] + /** + * @return array + */ public function all(): array { throw new RuntimeException('LazyFileConfig does not support full config retrieval. At least one key is required.'); } #[\Override] + /** + * @param string|array $key + */ public function fill(string|array $key, mixed $value = null): bool { if (is_array($key)) { @@ -48,14 +60,13 @@ public function fill(string|array $key, mixed $value = null): bool } #[\Override] + /** + * @param string|int|array $key + */ public function forget(string|int|array $key): bool { if (is_array($key)) { foreach ($key as $path) { - if (!is_string($path) && !is_int($path)) { - throw new InvalidArgumentException('Forget keys must be dot-notation strings.'); - } - $this->forgetPath((string) $path); } } else { @@ -66,6 +77,9 @@ public function forget(string|int|array $key): bool } #[\Override] + /** + * @param string|int|array|null $key + */ public function get(string|int|array|null $key = null, mixed $default = null): mixed { if ($key === null) { @@ -83,10 +97,6 @@ public function get(string|int|array|null $key = null, mixed $default = null): m $results = []; foreach ($key as $path) { - if (!is_string($path) && !is_int($path)) { - throw new InvalidArgumentException('Config keys must be dot-notation strings.'); - } - $results[(string) $path] = $this->getPath((string) $path, $default); } @@ -97,6 +107,9 @@ public function get(string|int|array|null $key = null, mixed $default = null): m } #[\Override] + /** + * @param string|array $keys + */ public function has(string|array $keys): bool { $keys = (array) $keys; @@ -104,20 +117,13 @@ public function has(string|array $keys): bool return false; } - foreach ($keys as $path) { - if (!is_string($path)) { - return false; - } - - if (!$this->hasPath($path)) { - return false; - } - } - - return true; + return array_all($keys, fn($path) => $this->hasPath($path)); } #[\Override] + /** + * @param string|array $keys + */ public function hasAny(string|array $keys): bool { $keys = (array) $keys; @@ -125,17 +131,7 @@ public function hasAny(string|array $keys): bool return false; } - foreach ($keys as $path) { - if (!is_string($path)) { - continue; - } - - if ($this->hasPath($path)) { - return true; - } - } - - return false; + return array_any($keys, fn($path) => $this->hasPath($path)); } /** @@ -146,6 +142,14 @@ public function isLoaded(string $namespace): bool return isset($this->loadedNamespaces[$this->normalizeNamespace($namespace)]); } + /** + * Alias of isLoaded(). + */ + public function loaded(string $namespace): bool + { + return $this->isLoaded($namespace); + } + /** * @return string[] List of namespaces already resolved. */ @@ -157,15 +161,11 @@ public function loadedNamespaces(): array /** * Preload one or multiple top-level config namespaces. * - * @param string|array $namespaces Namespace (e.g. "db") or list of namespaces. + * @param string|array $namespaces Namespace (e.g. "db") or list of namespaces. */ public function preload(string|array $namespaces): static { foreach ((array) $namespaces as $namespace) { - if (!is_string($namespace)) { - throw new InvalidArgumentException('Preload namespaces must be strings.'); - } - $this->loadNamespace($this->normalizeNamespace($namespace)); } @@ -173,6 +173,31 @@ public function preload(string|array $namespaces): static } #[\Override] + /** + * @param array|string $source + */ + public function reload(array|string $source): bool + { + $this->loadedNamespaces = []; + + return parent::reload($source); + } + + #[\Override] + /** + * @param array $items + */ + public function replace(array $items): bool + { + $this->loadedNamespaces = []; + + return parent::replace($items); + } + + #[\Override] + /** + * @param string|array|null $key + */ public function set(string|array|null $key = null, mixed $value = null, bool $overwrite = true): bool { if ($key === null) { @@ -222,13 +247,21 @@ protected function getPath(string $path, mixed $default): mixed $this->loadNamespace($namespace); if (!array_key_exists($namespace, $this->items)) { - return \isCallable($default) ? $default() : $default; + if ($default instanceof \Closure) { + return $default(); + } + + return $default; } if ($rest === null || $rest === '') { return $this->items[$namespace]; } + if (!is_array($this->items[$namespace])) { + return $default; + } + return DotNotation::get($this->items[$namespace], $rest, $default); } diff --git a/src/DTO/GenericDTO.php b/src/DTO/GenericDTO.php new file mode 100644 index 0000000..64a5044 --- /dev/null +++ b/src/DTO/GenericDTO.php @@ -0,0 +1,12 @@ + $arguments + */ public function __call(string $method, array $arguments): mixed { if (!method_exists($this->targetClass, $method)) { diff --git a/src/functions.php b/src/functions.php index 55ab54a..f743bab 100644 --- a/src/functions.php +++ b/src/functions.php @@ -2,7 +2,6 @@ declare(strict_types=1); - use Infocyph\ArrayKit\Collection\Collection; use Infocyph\ArrayKit\Collection\Pipeline; @@ -10,25 +9,25 @@ /** * Compare two values using a specified operator. * - * @param mixed $retrieved The value to compare - * @param mixed $value The reference value - * @param string|null $operator Supported operators: - * '!=', '<>', 'ne', '<', 'lt', '>', 'gt', - * '<=', 'lte', '>=', 'gte', '===', '!==' - * or null/default for '=='. + * @param mixed $retrieved The value to compare + * @param mixed $value The reference value + * @param string|null $operator Supported operators: + * '!=', '<>', 'ne', '<', 'lt', '>', 'gt', + * '<=', 'lte', '>=', 'gte', '===', '!==' + * or null/default for '=='. * @return bool True if comparison holds, false otherwise */ function compare(mixed $retrieved, mixed $value, ?string $operator = null): bool { return match ($operator) { '!=', '<>', 'ne' => $retrieved != $value, - '<', 'lt' => $retrieved < $value, - '>', 'gt' => $retrieved > $value, - '<=', 'lte' => $retrieved <= $value, - '>=', 'gte' => $retrieved >= $value, - '===' => $retrieved === $value, - '!==' => $retrieved !== $value, - default => $retrieved == $value, + '<', 'lt' => $retrieved < $value, + '>', 'gt' => $retrieved > $value, + '<=', 'lte' => $retrieved <= $value, + '>=', 'gte' => $retrieved >= $value, + '===' => $retrieved === $value, + '!==' => $retrieved !== $value, + default => $retrieved == $value, }; } } @@ -52,8 +51,8 @@ function isCallable(mixed $value): bool * - If an array of keys is provided, all values are returned in an array. * - If a single key is provided, the value is returned directly. * - * @param array $array The array to retrieve items from. - * @param int|string|array|null $key The key(s) to retrieve. + * @param array $array The array to retrieve items from. + * @param int|string|array|null $key The key(s) to retrieve. * @param mixed $default The default value to return if the key is not found. * @return mixed The retrieved value(s). */ @@ -71,7 +70,8 @@ function array_get(array $array, int|string|array|null $key = null, mixed $defau * If an array of key-value pairs is provided, each value is set. * If a single key is provided, the value is set directly. * - * @param array $array The array to set items in. + * @param array $array The array to set items in. + * @param string|array|null $key * @param mixed $value The value to set. * @param bool $overwrite If true, overwrite existing values. If false, existing values are preserved. * @return bool True on success @@ -85,18 +85,18 @@ function array_set(array &$array, string|array|null $key, mixed $value = null, b /** * Wrap the given value in an {@see Collection}. * - * @param mixed $data Anything “array-able”: array, Traversable, scalar, etc. + * @param mixed $data Anything “array-able”: array, Traversable, scalar, etc. */ function collect(mixed $data = []): Collection { return Collection::make($data); } } -if (! function_exists('chain')) { +if (!function_exists('chain')) { /** * Start a chainable pipeline on any “array-able” value. * - * @param mixed $data Array, Traversable, scalar, etc. + * @param mixed $data Array, Traversable, scalar, etc. */ function chain(mixed $data): Pipeline { diff --git a/src/namespaced-functions.php b/src/namespaced-functions.php new file mode 100644 index 0000000..0491f24 --- /dev/null +++ b/src/namespaced-functions.php @@ -0,0 +1,51 @@ + $array + * @param int|string|array|null $key + */ + function array_get(array $array, int|string|array|null $key = null, mixed $default = null): mixed + { + return \array_get($array, $key, $default); + } +} + +if (!function_exists(__NAMESPACE__ . '\\array_set')) { + /** + * @param array $array + * @param string|array|null $key + */ + function array_set(array &$array, string|array|null $key, mixed $value = null, bool $overwrite = true): bool + { + return \array_set($array, $key, $value, $overwrite); + } +} + +if (!function_exists(__NAMESPACE__ . '\\collect')) { + function collect(mixed $data = []): Collection + { + return \collect($data); + } +} + +if (!function_exists(__NAMESPACE__ . '\\chain')) { + function chain(mixed $data): Pipeline + { + return \chain($data); + } +} diff --git a/src/traits/DTOTrait.php b/src/traits/DTOTrait.php index 487dfb0..c99454e 100644 --- a/src/traits/DTOTrait.php +++ b/src/traits/DTOTrait.php @@ -29,7 +29,7 @@ trait DTOTrait * Unknown keys are ignored. Only properties matching * class property names will be set. * - * @param array $values Key-value pairs matching property names + * @param array $values Key-value pairs matching property names */ public static function create(array $values): static { @@ -41,7 +41,7 @@ public static function create(array $values): static * * Unknown keys are ignored. * - * @param array $values Key-value pairs matching property names + * @param array $values Key-value pairs matching property names */ public function fromArray(array $values): static { @@ -56,6 +56,8 @@ public function fromArray(array $values): static /** * Convert the current object’s public properties into an array. + * + * @return array */ public function toArray(): array { diff --git a/src/traits/HookTrait.php b/src/traits/HookTrait.php index d5a4246..1a2e09c 100644 --- a/src/traits/HookTrait.php +++ b/src/traits/HookTrait.php @@ -30,7 +30,7 @@ trait HookTrait /** * Attach a callable hook that runs when a value is retrieved (on get) for the given offset. * - * @param string $offset The key or offset + * @param string $offset The key or offset * @param callable $callback A transformation: fn($value) => $newValue */ public function onGet(string $offset, callable $callback): static @@ -41,7 +41,7 @@ public function onGet(string $offset, callable $callback): static /** * Attach a callable hook that runs when a value is assigned (on set) for the given offset. * - * @param string $offset The key or offset + * @param string $offset The key or offset * @param callable $callback A transformation: fn($value) => $newValue */ public function onSet(string $offset, callable $callback): static @@ -52,11 +52,11 @@ public function onSet(string $offset, callable $callback): static /** * Register a hook callback for a given offset and direction ("get" or "set"). * - * @param mixed $offset The key or offset (string recommended) - * @param string $direction Either "get" or "set" - * @param callable $callback The transformation function + * @param string|int $offset The key or offset + * @param string $direction Either "get" or "set" + * @param callable $callback The transformation function */ - protected function addHook(mixed $offset, string $direction, callable $callback): static + protected function addHook(string|int $offset, string $direction, callable $callback): static { $name = $this->getHookName((string) $offset, $direction); @@ -70,8 +70,8 @@ protected function addHook(mixed $offset, string $direction, callable $callback) /** * Construct the internal key for hooking, e.g. "offset-get" or "offset-set". * - * @param string $hook The offset or key - * @param string $direction Either "get" or "set" + * @param string $hook The offset or key + * @param string $direction Either "get" or "set" */ protected function getHookName(string $hook, string $direction): string { @@ -81,14 +81,14 @@ protected function getHookName(string $hook, string $direction): string /** * Apply any relevant hooks to a value before returning or storing it. * - * @param mixed $offset The key or offset - * @param mixed $value The value to be transformed + * @param string|int $offset The key or offset + * @param mixed $value The value to be transformed * @param string $direction Either "get" or "set" * @return mixed The possibly transformed value */ - protected function processValue(mixed $offset, mixed $value, string $direction): mixed + protected function processValue(string|int $offset, mixed $value, string $direction): mixed { - $name = $this->getHookName((string) $offset, $direction); + $name = $this->getHookName((string) $offset, $direction); $hooks = $this->hooks[$name] ?? []; foreach ($hooks as $hook) { diff --git a/tests/Feature/ArrayMultiTest.php b/tests/Feature/ArrayMultiTest.php index a817178..fcfe300 100644 --- a/tests/Feature/ArrayMultiTest.php +++ b/tests/Feature/ArrayMultiTest.php @@ -346,3 +346,88 @@ 1 => ['score' => 50], ]); }); + +it('handles null values correctly in whereIn()', function () { + $rows = [ + ['id' => 1, 'role' => null], + ['id' => 2, 'role' => 'admin'], + ['id' => 3], + ]; + + expect(ArrayMulti::whereIn($rows, 'role', [null], true))->toBe([ + 0 => ['id' => 1, 'role' => null], + ]); +}); + +it('handles null values and missing keys correctly in whereNotIn()', function () { + $rows = [ + ['id' => 1, 'role' => null], + ['id' => 2, 'role' => 'admin'], + ['id' => 3], + ]; + + expect(ArrayMulti::whereNotIn($rows, 'role', [null], true))->toBe([ + 1 => ['id' => 2, 'role' => 'admin'], + 2 => ['id' => 3], + ]); +}); + +it('supports keyBy/indexBy/countBy/firstWhere/mapWithKeys helpers', function () { + $rows = [ + ['id' => 10, 'team' => 'A', 'score' => 40], + ['id' => 11, 'team' => 'B', 'score' => 70], + ['id' => 12, 'team' => 'A', 'score' => 55], + ]; + + expect(ArrayMulti::keyBy($rows, 'id'))->toBe([ + 10 => ['id' => 10, 'team' => 'A', 'score' => 40], + 11 => ['id' => 11, 'team' => 'B', 'score' => 70], + 12 => ['id' => 12, 'team' => 'A', 'score' => 55], + ]) + ->and(ArrayMulti::indexBy($rows, fn (array $row) => 'row_' . $row['id']))->toBe([ + 'row_10' => ['id' => 10, 'team' => 'A', 'score' => 40], + 'row_11' => ['id' => 11, 'team' => 'B', 'score' => 70], + 'row_12' => ['id' => 12, 'team' => 'A', 'score' => 55], + ]) + ->and(ArrayMulti::countBy($rows, 'team'))->toBe(['A' => 2, 'B' => 1]) + ->and(ArrayMulti::firstWhere($rows, 'score', '>=', 50))->toBe(['id' => 11, 'team' => 'B', 'score' => 70]) + ->and(ArrayMulti::mapWithKeys($rows, fn (array $row) => [$row['id'] => $row['team']]))->toBe([10 => 'A', 11 => 'B', 12 => 'A']); +}); + +it('supports min/max/minBy/maxBy helpers', function () { + $rows = [ + ['id' => 1, 'score' => 40], + ['id' => 2, 'score' => 70], + ['id' => 3, 'score' => 55], + ]; + + expect(ArrayMulti::min($rows, 'score'))->toBe(40) + ->and(ArrayMulti::max($rows, 'score'))->toBe(70) + ->and(ArrayMulti::minBy($rows, 'score'))->toBe(['id' => 1, 'score' => 40]) + ->and(ArrayMulti::maxBy($rows, 'score'))->toBe(['id' => 2, 'score' => 70]); +}); + +it('supports values, rekey, and deep merge helpers', function () { + $assoc = ['a' => ['v' => 1], 'b' => ['v' => 2]]; + + expect(ArrayMulti::values($assoc))->toBe([['v' => 1], ['v' => 2]]) + ->and(ArrayMulti::rekey(['first_name' => 'Ada'], ['first_name' => 'firstName']))->toBe(['firstName' => 'Ada']) + ->and(ArrayMulti::mergeRecursiveDistinct( + ['db' => ['host' => 'localhost', 'opts' => ['timeout' => 5]]], + ['db' => ['opts' => ['timeout' => 10, 'ssl' => true]]], + ))->toBe([ + 'db' => ['host' => 'localhost', 'opts' => ['timeout' => 10, 'ssl' => true]], + ]) + ->and(ArrayMulti::replaceRecursive( + ['app' => ['name' => 'ArrayKit', 'env' => 'local']], + ['app' => ['env' => 'prod']], + ))->toBe([ + 'app' => ['name' => 'ArrayKit', 'env' => 'prod'], + ]) + ->and(ArrayMulti::overlay( + ['a' => ['b' => 1]], + ['a' => ['c' => 2]], + ))->toBe([ + 'a' => ['b' => 1, 'c' => 2], + ]); +}); diff --git a/tests/Feature/ArraySharedOpsTest.php b/tests/Feature/ArraySharedOpsTest.php new file mode 100644 index 0000000..7078f16 --- /dev/null +++ b/tests/Feature/ArraySharedOpsTest.php @@ -0,0 +1,58 @@ +toBe([10, 20]) + ->and($result)->toBe([10, 20, 30]); +}); + +it('evaluates every with callback', function () { + expect(ArraySharedOps::every([2, 4, 6], fn (int $value): bool => $value % 2 === 0))->toBeTrue() + ->and(ArraySharedOps::every([2, 3, 6], fn (int $value): bool => $value % 2 === 0))->toBeFalse(); +}); + +it('partitions arrays by callback result', function () { + [$pass, $fail] = ArraySharedOps::partition( + ['a' => 10, 'b' => 25, 'c' => 30], + fn (int $value): bool => $value >= 20, + ); + + expect($pass)->toBe(['b' => 25, 'c' => 30]) + ->and($fail)->toBe(['a' => 10]); +}); + +it('supports skip, skipWhile, and skipUntil with key preservation', function () { + $data = ['a' => 1, 'b' => 2, 'c' => 3, 'd' => 4]; + + expect(ArraySharedOps::skip($data, 2))->toBe(['c' => 3, 'd' => 4]) + ->and(ArraySharedOps::skipWhile($data, fn (int $value): bool => $value < 3))->toBe(['c' => 3, 'd' => 4]) + ->and(ArraySharedOps::skipUntil($data, fn (int $value): bool => $value === 3))->toBe(['c' => 3, 'd' => 4]); +}); + +it('normalizes array keys from mixed values', function () { + $stringable = new class() { + public function __toString(): string + { + return 'obj-key'; + } + }; + + expect(ArraySharedOps::normalizeArrayKey(9))->toBe(9) + ->and(ArraySharedOps::normalizeArrayKey('x'))->toBe('x') + ->and(ArraySharedOps::normalizeArrayKey(true))->toBe('1') + ->and(ArraySharedOps::normalizeArrayKey(1.5))->toBe('1.5') + ->and(ArraySharedOps::normalizeArrayKey(null))->toBe('') + ->and(ArraySharedOps::normalizeArrayKey(['a' => 1]))->toBe('{"a":1}') + ->and(ArraySharedOps::normalizeArrayKey($stringable))->toBe('obj-key'); +}); diff --git a/tests/Feature/ArraySingleTest.php b/tests/Feature/ArraySingleTest.php index ffc5937..032dfb6 100644 --- a/tests/Feature/ArraySingleTest.php +++ b/tests/Feature/ArraySingleTest.php @@ -29,6 +29,10 @@ expect(ArraySingle::isList([]))->toBeTrue(); }); +it('treats an empty array as non-associative', function () { + expect(ArraySingle::isAssoc([]))->toBeFalse(); +}); + it('calculates average of numeric values', function () { $nums = [2, 4, 6, 8]; expect(ArraySingle::avg($nums))->toBe(5); @@ -78,6 +82,13 @@ ->and(ArraySingle::unique([1, '1', 2, 3], true)) ->toBe([1, '1', 2, 3]); // Strict comparison }); + +it('handles unique() with mixed values in loose and strict modes', function () { + $arr = [1, '1', true, [1], ['1']]; + + expect(ArraySingle::unique($arr))->toBe([1, [1]]) + ->and(ArraySingle::unique($arr, true))->toBe([1, '1', true, [1], ['1']]); +}); it('slices the array using slice()', function () { $arr = [1, 2, 3, 4, 5]; expect(ArraySingle::slice($arr, 1, 3)) @@ -114,3 +125,45 @@ it('throws for invalid nth step values', function () { expect(fn () => ArraySingle::nth([1, 2, 3], 0))->toThrow(InvalidArgumentException::class); }); + +it('throws for invalid nth offset values', function () { + expect(fn () => ArraySingle::nth([1, 2, 3], 1, -1))->toThrow(InvalidArgumentException::class); +}); + +it('selects nth values using step and offset semantics', function () { + $arr = [10, 20, 30, 40, 50, 60]; + + expect(ArraySingle::nth($arr, 2, 0))->toBe([10, 30, 50]) + ->and(ArraySingle::nth($arr, 2, 1))->toBe([20, 40, 60]) + ->and(ArraySingle::nth($arr, 2, 2))->toBe([30, 50]) + ->and(ArraySingle::nth($arr, 2, 4))->toBe([50]); +}); + +it('supports countBy, min/max, minBy/maxBy, mapWithKeys, values and rekey helpers', function () { + $rows = [ + ['id' => 1, 'group' => 'a', 'score' => 10], + ['id' => 2, 'group' => 'a', 'score' => 30], + ['id' => 3, 'group' => 'b', 'score' => 20], + ]; + + expect(ArraySingle::countBy([1, 2, 2, 3, 3, 3]))->toBe([1 => 1, 2 => 2, 3 => 3]) + ->and(ArraySingle::countBy($rows, fn (array $row) => $row['group']))->toBe(['a' => 2, 'b' => 1]) + ->and(ArraySingle::min([9, 2, 4]))->toBe(2) + ->and(ArraySingle::max([9, 2, 4]))->toBe(9) + ->and(ArraySingle::minBy($rows, fn (array $row) => $row['score']))->toBe(['id' => 1, 'group' => 'a', 'score' => 10]) + ->and(ArraySingle::maxBy($rows, fn (array $row) => $row['score']))->toBe(['id' => 2, 'group' => 'a', 'score' => 30]) + ->and(ArraySingle::mapWithKeys($rows, fn (array $row) => [$row['id'] => $row['score']]))->toBe([1 => 10, 2 => 30, 3 => 20]) + ->and(ArraySingle::values(['x' => 1, 'y' => 2]))->toBe([1, 2]) + ->and(ArraySingle::rekey(['first_name' => 'Ada'], ['first_name' => 'firstName']))->toBe(['firstName' => 'Ada']); +}); + +it('supports intersect, diff, symmetricDiff and same helpers', function () { + $left = [1, 2, 3, '3']; + $right = [3, 4, '3']; + + expect(ArraySingle::intersect($left, $right))->toBe([2 => 3, 3 => '3']) + ->and(ArraySingle::diff($left, $right))->toBe([0 => 1, 1 => 2]) + ->and(ArraySingle::symmetricDiff([1, 2, 3], [3, 4]))->toBe([1, 2, 4]) + ->and(ArraySingle::same([1, 2, 2], [2, 1, 2]))->toBeTrue() + ->and(ArraySingle::same([1, 2], ['1', 2], true))->toBeFalse(); +}); diff --git a/tests/Feature/BucketCollectionTest.php b/tests/Feature/BucketCollectionTest.php index b335355..99ab911 100644 --- a/tests/Feature/BucketCollectionTest.php +++ b/tests/Feature/BucketCollectionTest.php @@ -24,6 +24,21 @@ expect($keys)->toBe(['a', 'b']); }); +it('supports nested iteration without pointer side effects', function () { + $collection = new Collection(['a' => 1, 'b' => 2, 'c' => 3]); + $pairs = []; + + foreach ($collection as $outerKey => $outerValue) { + foreach ($collection as $innerKey => $innerValue) { + $pairs[] = [$outerKey, $innerKey, $outerValue + $innerValue]; + } + } + + expect(count($pairs))->toBe(9) + ->and($pairs[0])->toBe(['a', 'a', 2]) + ->and($pairs[8])->toBe(['c', 'c', 6]); +}); + it('provides a merge method', function () { $c1 = new Collection(['a' => 1]); $c2 = new Collection(['b' => 2]); @@ -43,3 +58,14 @@ expect(isset($collection['nullable']))->toBeTrue(); }); + +it('supports copy and immutable snapshots', function () { + $collection = new Collection(['a' => 1, 'b' => 2]); + $copy = $collection->copy(); + $immutable = $collection->immutable(); + + $collection['a'] = 99; + + expect($copy->all())->toBe(['a' => 1, 'b' => 2]) + ->and($immutable->all())->toBe(['a' => 1, 'b' => 2]); +}); diff --git a/tests/Feature/ConfigTest.php b/tests/Feature/ConfigTest.php index 7a93c6c..50c1686 100644 --- a/tests/Feature/ConfigTest.php +++ b/tests/Feature/ConfigTest.php @@ -28,3 +28,23 @@ ->toBeTrue() ->and($config->has('app.unknown'))->toBeFalse(); }); + +it('supports replace and reload operations', function () { + $cfg = new Config(); + $cfg->loadArray(['app' => ['name' => 'ArrayKit']]); + + $cfg->replace(['app' => ['name' => 'ArrayKitX']]); + expect($cfg->get('app.name'))->toBe('ArrayKitX'); + + $cfg->reload(['db' => ['host' => 'localhost']]); + expect($cfg->get('db.host'))->toBe('localhost') + ->and($cfg->has('app.name'))->toBeFalse(); +}); + +it('supports getOrFail for required keys', function () { + $cfg = new Config(); + $cfg->loadArray(['app' => ['name' => 'ArrayKit']]); + + expect($cfg->getOrFail('app.name'))->toBe('ArrayKit') + ->and(fn () => $cfg->getOrFail('app.missing'))->toThrow(OutOfBoundsException::class); +}); diff --git a/tests/Feature/DotNotationTest.php b/tests/Feature/DotNotationTest.php index 5fb5507..3fb9dbb 100644 --- a/tests/Feature/DotNotationTest.php +++ b/tests/Feature/DotNotationTest.php @@ -138,11 +138,47 @@ expect(DotNotation::get($data, 'b', 'default'))->toBe('default'); }); +it('returns null when key exists with null value', function () { + $data = ['user' => ['middle_name' => null]]; + + expect(DotNotation::get($data, 'user.middle_name', 'fallback'))->toBeNull(); +}); + +it('does not treat existing value equal to default as missing', function () { + $data = ['app' => ['env' => 'local']]; + + expect(DotNotation::get($data, 'app.env', 'local'))->toBe('local'); +}); + +it('evaluates callable default once when key path is missing', function () { + $data = ['app' => ['name' => 'ArrayKit']]; + $calls = 0; + + $value = DotNotation::get($data, 'app.env.current', function () use (&$calls) { + $calls++; + + return 'fallback'; + }); + + expect($value)->toBe('fallback') + ->and($calls)->toBe(1); +}); + it('returns string defaults as-is when key is not found', function () { $data = []; expect(DotNotation::get($data, 'missing.key', 'file'))->toBe('file'); }); +it('supports escaped dot paths for literal dot keys', function () { + $data = [ + 'service.name' => 'ArrayKit', + 'service' => ['name' => 'Nested'], + ]; + + expect(DotNotation::get($data, 'service\\.name'))->toBe('ArrayKit') + ->and(DotNotation::has($data, 'service\\.name'))->toBeTrue(); +}); + it('retrieves multiple keys when passed an array', function () { $data = [ 'user' => ['name' => 'Carol', 'email' => 'carol@example.com'], @@ -198,6 +234,35 @@ expect($data['user']['email'])->toBe('frank@example.com'); }); +it('supports escaped dot paths for set and forget', function () { + $data = []; + DotNotation::set($data, 'service\\.name', 'ArrayKit'); + + expect($data)->toBe(['service.name' => 'ArrayKit']); + + DotNotation::forget($data, 'service\\.name'); + expect($data)->toBe([]); +}); + +it('supports wildcard set and wildcard forget', function () { + $data = [ + 'users' => [ + ['name' => 'Alice', 'active' => false, 'secret' => 'a'], + ['name' => 'Bob', 'active' => false, 'secret' => 'b'], + ], + ]; + + DotNotation::set($data, 'users.*.active', true); + DotNotation::forget($data, 'users.*.secret'); + + expect($data)->toBe([ + 'users' => [ + ['name' => 'Alice', 'active' => true], + ['name' => 'Bob', 'active' => true], + ], + ]); +}); + // // Test type-specific retrieval: string, integer, float, boolean, arrayValue // diff --git a/tests/Feature/GlobalHelpersTest.php b/tests/Feature/GlobalHelpersTest.php new file mode 100644 index 0000000..e3fd7b6 --- /dev/null +++ b/tests/Feature/GlobalHelpersTest.php @@ -0,0 +1,32 @@ +toContain("if (!function_exists('compare'))") + ->and($source)->toContain("if (!function_exists('array_get'))") + ->and($source)->toContain("if (!function_exists('array_set'))") + ->and($source)->toContain("if (!function_exists('collect'))") + ->and($source)->toContain("if (!function_exists('chain'))"); +}); + +it('provides namespaced helper alternatives', function () { + $data = ['app' => ['name' => 'ArrayKit']]; + ns_array_set($data, 'app.env', 'local'); + + expect(ns_array_get($data, 'app.name'))->toBe('ArrayKit') + ->and(ns_array_get($data, 'app.env'))->toBe('local') + ->and(ns_compare(10, 5, '>'))->toBeTrue() + ->and(ns_collect([1, 2]))->toBeInstanceOf(Collection::class) + ->and(ns_chain([1, 2, 3]))->toBeInstanceOf(Pipeline::class); +}); diff --git a/tests/Feature/LazyFileConfigTest.php b/tests/Feature/LazyFileConfigTest.php index c7e9b76..c8a5d8e 100644 --- a/tests/Feature/LazyFileConfigTest.php +++ b/tests/Feature/LazyFileConfigTest.php @@ -106,6 +106,16 @@ function lazyConfigItems(LazyFileConfig $config): array expect($loaded)->toBe(['cache', 'db']); }); +it('supports loaded() alias for namespace checks', function () { + lazyConfigWriteArrayFile($this->configPath, 'db', ['host' => 'localhost']); + + $config = new LazyFileConfig($this->configPath); + $config->get('db.host'); + + expect($config->loaded('db'))->toBeTrue() + ->and($config->loaded('app'))->toBeFalse(); +}); + it('loads all requested namespaces for multi-key lookup', function () { lazyConfigWriteArrayFile($this->configPath, 'db', ['host' => 'localhost']); lazyConfigWriteArrayFile($this->configPath, 'app', ['name' => 'ArrayKit']); @@ -209,3 +219,18 @@ function lazyConfigItems(LazyFileConfig $config): array 'db.port' => 5432, ]); }); + +it('supports replace/reload and required key access in lazy config', function () { + lazyConfigWriteArrayFile($this->configPath, 'db', ['host' => 'localhost']); + + $config = new LazyFileConfig($this->configPath); + $config->get('db.host'); + + $config->replace(['app' => ['name' => 'ArrayKit']]); + expect($config->get('app.name'))->toBe('ArrayKit') + ->and($config->getOrFail('app.name'))->toBe('ArrayKit') + ->and(fn () => $config->getOrFail('queue.driver'))->toThrow(OutOfBoundsException::class); + + $config->reload(['cache' => ['driver' => 'file']]); + expect($config->get('cache.driver'))->toBe('file'); +}); diff --git a/tests/Feature/PipelineTest.php b/tests/Feature/PipelineTest.php new file mode 100644 index 0000000..dad4bc9 --- /dev/null +++ b/tests/Feature/PipelineTest.php @@ -0,0 +1,70 @@ + 1, 'team' => 'A', 'score' => 30], + ['id' => 2, 'team' => 'B', 'score' => 50], + ['id' => 3, 'team' => 'A', 'score' => 40], + ]); + + expect($rows->copy()->keyBy('id')->all())->toBe([ + 1 => ['id' => 1, 'team' => 'A', 'score' => 30], + 2 => ['id' => 2, 'team' => 'B', 'score' => 50], + 3 => ['id' => 3, 'team' => 'A', 'score' => 40], + ]) + ->and($rows->copy()->indexBy(fn (array $row) => 'r' . $row['id'])->all())->toBe([ + 'r1' => ['id' => 1, 'team' => 'A', 'score' => 30], + 'r2' => ['id' => 2, 'team' => 'B', 'score' => 50], + 'r3' => ['id' => 3, 'team' => 'A', 'score' => 40], + ]) + ->and($rows->copy()->mapWithKeys(fn (array $row) => [$row['id'] => $row['score']])->all())->toBe([1 => 30, 2 => 50, 3 => 40]) + ->and($rows->process()->countBy('team'))->toBe(['A' => 2, 'B' => 1]); +}); + +it('supports min/max/minBy/maxBy and set helpers in pipelines', function () { + $numbers = Collection::make([1, 2, 2, 3]); + + expect($numbers->process()->min())->toBe(1) + ->and($numbers->process()->max())->toBe(3) + ->and($numbers->copy()->intersect([2, 4])->all())->toBe([1 => 2, 2 => 2]) + ->and($numbers->copy()->diff([2])->all())->toBe([0 => 1, 3 => 3]) + ->and($numbers->copy()->symmetricDiff([3, 4])->all())->toBe([1, 2, 2, 4]) + ->and($numbers->process()->same([2, 1, 2, 3]))->toBeTrue(); + + $rows = Collection::make([ + ['id' => 1, 'score' => 10], + ['id' => 2, 'score' => 40], + ['id' => 3, 'score' => 20], + ]); + + expect($rows->process()->min('score'))->toBe(10) + ->and($rows->process()->max('score'))->toBe(40) + ->and($rows->process()->minBy('score'))->toBe(['id' => 1, 'score' => 10]) + ->and($rows->process()->maxBy('score'))->toBe(['id' => 2, 'score' => 40]) + ->and($rows->process()->firstWhere('score', '>=', 20))->toBe(['id' => 2, 'score' => 40]); +}); + +it('supports values, rekey, deep merge helpers, and unwrap alias', function () { + $collection = Collection::make(['first_name' => 'Ada', 'last_name' => 'Lovelace']); + $renamed = $collection->copy()->rekey(['first_name' => 'firstName'])->all(); + + expect($renamed)->toBe([ + 'firstName' => 'Ada', + 'last_name' => 'Lovelace', + ]); + + $overlay = Collection::make(['db' => ['host' => 'localhost', 'opts' => ['timeout' => 5]]]); + $merged = $overlay->copy()->overlay(['db' => ['opts' => ['ssl' => true]]])->all(); + expect($merged)->toBe([ + 'db' => ['host' => 'localhost', 'opts' => ['timeout' => 5, 'ssl' => true]], + ]); + + $single = Collection::make(['only']); + expect($single->copy()->unwrap()->all())->toBe(['only']) + ->and($single->copy()->unWrap()->all())->toBe(['only']) + ->and($single->copy()->values()->all())->toBe(['only']); +});