From d12d4202a4b6a2a8811f7cc4b5e67501a530a167 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Thu, 14 May 2026 16:43:53 +0600 Subject: [PATCH] ic+improvements --- .github/scripts/composer-audit-guard.php | 85 -- .github/scripts/phpstan-sarif.php | 178 ---- .github/scripts/syntax.php | 109 --- .github/workflows/build.yml | 128 --- .github/workflows/security-standards.yml | 40 + README.md | 15 +- benchmarks/CoreBench.php | 79 ++ captainhook.json | 8 +- composer.json | 96 +- docs/array-helpers.rst | 52 ++ docs/collection.rst | 20 +- docs/config.rst | 10 +- docs/dot-notation.rst | 19 +- docs/index.rst | 1 + docs/migration.rst | 30 + docs/rule-reference.rst | 71 ++ docs/traits-and-helpers.rst | 8 + pest.xml | 22 - phpbench.json | 26 - phpcs.xml.dist | 52 -- phpstan.neon.dist | 14 - phpunit.xml | 22 - pint.json | 73 -- psalm.xml | 40 - rector.php | 14 - src/Array/ArrayMulti.php | 702 +++------------ src/Array/ArraySharedOps.php | 124 +++ src/Array/ArraySingle.php | 461 +++++++--- src/Array/ArraySingleOps.php | 327 +++++++ src/Array/BaseArrayHelper.php | 67 +- .../Concerns/ArrayMultiQuerySortTrait.php | 587 +++++++++++++ .../Concerns/DotNotationPublicApiTrait.php | 300 +++++++ src/Array/DotNotation.php | 818 +++--------------- src/Array/DotNotationPathOps.php | 229 +++++ src/ArrayKit.php | 6 + src/Collection/BaseCollectionTrait.php | 96 +- src/Collection/Collection.php | 10 +- src/Collection/HookedCollection.php | 13 +- src/Collection/Pipeline.php | 292 ++++++- src/Config/BaseConfigTrait.php | 116 ++- src/Config/Config.php | 18 + src/Config/LazyFileConfig.php | 105 ++- src/DTO/GenericDTO.php | 12 + src/Facade/ModuleProxy.php | 3 + src/functions.php | 40 +- src/namespaced-functions.php | 51 ++ src/traits/DTOTrait.php | 6 +- src/traits/HookTrait.php | 24 +- tests/Feature/ArrayMultiTest.php | 85 ++ tests/Feature/ArraySharedOpsTest.php | 58 ++ tests/Feature/ArraySingleTest.php | 53 ++ tests/Feature/BucketCollectionTest.php | 26 + tests/Feature/ConfigTest.php | 20 + tests/Feature/DotNotationTest.php | 65 ++ tests/Feature/GlobalHelpersTest.php | 32 + tests/Feature/LazyFileConfigTest.php | 25 + tests/Feature/PipelineTest.php | 70 ++ 57 files changed, 3632 insertions(+), 2421 deletions(-) delete mode 100644 .github/scripts/composer-audit-guard.php delete mode 100644 .github/scripts/phpstan-sarif.php delete mode 100644 .github/scripts/syntax.php delete mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/security-standards.yml create mode 100644 docs/migration.rst delete mode 100644 pest.xml delete mode 100644 phpbench.json delete mode 100644 phpcs.xml.dist delete mode 100644 phpstan.neon.dist delete mode 100644 phpunit.xml delete mode 100644 pint.json delete mode 100644 psalm.xml delete mode 100644 rector.php create mode 100644 src/Array/ArraySharedOps.php create mode 100644 src/Array/ArraySingleOps.php create mode 100644 src/Array/Concerns/ArrayMultiQuerySortTrait.php create mode 100644 src/Array/Concerns/DotNotationPublicApiTrait.php create mode 100644 src/Array/DotNotationPathOps.php create mode 100644 src/DTO/GenericDTO.php create mode 100644 src/namespaced-functions.php create mode 100644 tests/Feature/ArraySharedOpsTest.php create mode 100644 tests/Feature/GlobalHelpersTest.php create mode 100644 tests/Feature/PipelineTest.php 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']); +});