diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml index 93c7b1a..c954308 100644 --- a/.github/workflows/phpunit.yml +++ b/.github/workflows/phpunit.yml @@ -5,13 +5,21 @@ on: [ push ] jobs: build-test: runs-on: ubuntu-latest + strategy: + matrix: + php-version: ['7.4', '8.5'] steps: - uses: actions/checkout@v3 - - uses: php-actions/composer@v6 + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + tools: composer:v2 + coverage: none + + - name: Install dependencies + run: composer install --no-interaction --prefer-dist --no-progress - name: PHPUnit Tests - uses: php-actions/phpunit@v3 - with: - configuration: phpunit.xml \ No newline at end of file + run: vendor/bin/phpunit --configuration phpunit.xml diff --git a/.wordlist.txt b/.wordlist.txt index 7261aef..519e583 100644 --- a/.wordlist.txt +++ b/.wordlist.txt @@ -23,4 +23,11 @@ Traceback nodejs npm fediverse -readme \ No newline at end of file +readme +chainable +getter +lookups +PHPNomad +phpnomad +txt +utils diff --git a/composer.json b/composer.json index 42d62df..5bb16fc 100644 --- a/composer.json +++ b/composer.json @@ -1,7 +1,6 @@ { "name": "phpnomad/utils", "description": "", - "version": "1.0.4", "type": "library", "homepage": "https://github.com/phpnomad/core", "readme": "README.md", @@ -12,11 +11,15 @@ "autoload": { "psr-4": { "PHPNomad\\Utils\\": "lib/" - } + }, + "classmap": [ + "lib/Exceptions/" + ] }, "autoload-dev": { "psr-4": { - "PHPNomad\\Utils\\Tests\\": "tests/" + "PHPNomad\\Core\\Tests\\": "tests/", + "PHPNomad\\Tests\\Unit\\": "tests/Unit/" } }, "authors": [ @@ -26,7 +29,11 @@ } ], "require-dev": { - "phpnomad/tests": "^0.1.0 || ^0.3.0" + "doctrine/instantiator": "^1.5", + "myclabs/deep-copy": ">=1.13.4 <1.14", + "phpnomad/tests": "^0.1.0 || ^0.3.0", + "phpunit/phpunit": "^9.6.33", + "symfony/process": "^5.4.51" }, "config": { "allow-plugins": { diff --git a/composer.lock b/composer.lock index 97b4cdb..da75879 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "7ebdd708c3cda9b0c3d7d74dc3e177a7", + "content-hash": "9ea35e5a071b398d863b6cd9d6e765ee", "packages": [], "packages-dev": [ { @@ -714,16 +714,16 @@ }, { "name": "myclabs/deep-copy", - "version": "1.12.1", + "version": "1.13.4", "source": { "type": "git", "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "123267b2c49fbf30d78a7b2d333f6be754b94845" + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/123267b2c49fbf30d78a7b2d333f6be754b94845", - "reference": "123267b2c49fbf30d78a7b2d333f6be754b94845", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", "shasum": "" }, "require": { @@ -762,7 +762,7 @@ ], "support": { "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.12.1" + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" }, "funding": [ { @@ -770,24 +770,23 @@ "type": "tidelift" } ], - "time": "2024-11-08T17:47:46+00:00" + "time": "2025-08-01T08:46:24+00:00" }, { "name": "nikic/php-parser", - "version": "v5.3.1", + "version": "v5.9.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "8eea230464783aa9671db8eea6f8c6ac5285794b" + "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/8eea230464783aa9671db8eea6f8c6ac5285794b", - "reference": "8eea230464783aa9671db8eea6f8c6ac5285794b", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/9e33da9553fe7786f0962b35f4e4ecf01be89def", + "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def", "shasum": "" }, "require": { - "ext-ctype": "*", "ext-json": "*", "ext-tokenizer": "*", "php": ">=7.4" @@ -802,7 +801,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "5.0-dev" + "dev-master": "5.x-dev" } }, "autoload": { @@ -826,9 +825,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.3.1" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.9.0" }, - "time": "2024-10-08T18:51:32+00:00" + "time": "2026-09-13T18:51:52+00:00" }, { "name": "phar-io/manifest", @@ -1473,27 +1472,27 @@ }, { "name": "phpunit/phpunit", - "version": "9.6.22", + "version": "9.6.36", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "f80235cb4d3caa59ae09be3adf1ded27521d1a9c" + "reference": "abab27ed286d3e1246fbbfe6b56bfd732d945ec9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/f80235cb4d3caa59ae09be3adf1ded27521d1a9c", - "reference": "f80235cb4d3caa59ae09be3adf1ded27521d1a9c", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/abab27ed286d3e1246fbbfe6b56bfd732d945ec9", + "reference": "abab27ed286d3e1246fbbfe6b56bfd732d945ec9", "shasum": "" }, "require": { "doctrine/instantiator": "^1.5.0 || ^2", "ext-dom": "*", + "ext-filter": "*", "ext-json": "*", "ext-libxml": "*", "ext-mbstring": "*", - "ext-xml": "*", "ext-xmlwriter": "*", - "myclabs/deep-copy": "^1.12.1", + "myclabs/deep-copy": "^1.13.4", "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=7.3", @@ -1504,11 +1503,11 @@ "phpunit/php-timer": "^5.0.3", "sebastian/cli-parser": "^1.0.2", "sebastian/code-unit": "^1.0.8", - "sebastian/comparator": "^4.0.8", + "sebastian/comparator": "^4.0.10", "sebastian/diff": "^4.0.6", "sebastian/environment": "^5.1.5", - "sebastian/exporter": "^4.0.6", - "sebastian/global-state": "^5.0.7", + "sebastian/exporter": "^4.0.9", + "sebastian/global-state": "^5.0.8", "sebastian/object-enumerator": "^4.0.4", "sebastian/resource-operations": "^3.0.4", "sebastian/type": "^3.2.1", @@ -1556,23 +1555,15 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.22" + "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.36" }, "funding": [ { - "url": "https://phpunit.de/sponsors.html", - "type": "custom" - }, - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", - "type": "tidelift" + "url": "https://phpunit.de/sponsoring.html", + "type": "other" } ], - "time": "2024-12-05T13:48:26+00:00" + "time": "2026-08-11T06:25:15+00:00" }, { "name": "psr/container", @@ -2421,16 +2412,16 @@ }, { "name": "sebastian/comparator", - "version": "4.0.8", + "version": "4.0.10", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "fa0f136dd2334583309d32b62544682ee972b51a" + "reference": "e4df00b9b3571187db2831ae9aada2c6efbd715d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/fa0f136dd2334583309d32b62544682ee972b51a", - "reference": "fa0f136dd2334583309d32b62544682ee972b51a", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/e4df00b9b3571187db2831ae9aada2c6efbd715d", + "reference": "e4df00b9b3571187db2831ae9aada2c6efbd715d", "shasum": "" }, "require": { @@ -2483,15 +2474,27 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", - "source": "https://github.com/sebastianbergmann/comparator/tree/4.0.8" + "source": "https://github.com/sebastianbergmann/comparator/tree/4.0.10" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" } ], - "time": "2022-09-14T12:41:17+00:00" + "time": "2026-01-24T09:22:56+00:00" }, { "name": "sebastian/complexity", @@ -2681,16 +2684,16 @@ }, { "name": "sebastian/exporter", - "version": "4.0.6", + "version": "4.0.9", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "78c00df8f170e02473b682df15bfcdacc3d32d72" + "reference": "4352c1a3df741a7ba9e61af6fed51d1fee41cbf7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/78c00df8f170e02473b682df15bfcdacc3d32d72", - "reference": "78c00df8f170e02473b682df15bfcdacc3d32d72", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/4352c1a3df741a7ba9e61af6fed51d1fee41cbf7", + "reference": "4352c1a3df741a7ba9e61af6fed51d1fee41cbf7", "shasum": "" }, "require": { @@ -2746,28 +2749,40 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/exporter/issues", - "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.6" + "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.9" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" } ], - "time": "2024-03-02T06:33:00+00:00" + "time": "2026-08-11T04:55:59+00:00" }, { "name": "sebastian/global-state", - "version": "5.0.7", + "version": "5.0.8", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/global-state.git", - "reference": "bca7df1f32ee6fe93b4d4a9abbf69e13a4ada2c9" + "reference": "b6781316bdcd28260904e7cc18ec983d0d2ef4f6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/bca7df1f32ee6fe93b4d4a9abbf69e13a4ada2c9", - "reference": "bca7df1f32ee6fe93b4d4a9abbf69e13a4ada2c9", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/b6781316bdcd28260904e7cc18ec983d0d2ef4f6", + "reference": "b6781316bdcd28260904e7cc18ec983d0d2ef4f6", "shasum": "" }, "require": { @@ -2810,15 +2825,27 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/global-state/issues", - "source": "https://github.com/sebastianbergmann/global-state/tree/5.0.7" + "source": "https://github.com/sebastianbergmann/global-state/tree/5.0.8" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/global-state", + "type": "tidelift" } ], - "time": "2024-03-02T06:35:11+00:00" + "time": "2025-08-10T07:10:35+00:00" }, { "name": "sebastian/lines-of-code", @@ -2991,16 +3018,16 @@ }, { "name": "sebastian/recursion-context", - "version": "4.0.5", + "version": "4.0.7", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/recursion-context.git", - "reference": "e75bd0f07204fec2a0af9b0f3cfe97d05f92efc1" + "reference": "c85be6922b7fd365942b986b9a50397d65407611" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/e75bd0f07204fec2a0af9b0f3cfe97d05f92efc1", - "reference": "e75bd0f07204fec2a0af9b0f3cfe97d05f92efc1", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/c85be6922b7fd365942b986b9a50397d65407611", + "reference": "c85be6922b7fd365942b986b9a50397d65407611", "shasum": "" }, "require": { @@ -3042,15 +3069,27 @@ "homepage": "https://github.com/sebastianbergmann/recursion-context", "support": { "issues": "https://github.com/sebastianbergmann/recursion-context/issues", - "source": "https://github.com/sebastianbergmann/recursion-context/tree/4.0.5" + "source": "https://github.com/sebastianbergmann/recursion-context/tree/4.0.7" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" } ], - "time": "2023-02-03T06:07:39+00:00" + "time": "2026-08-11T05:25:24+00:00" }, { "name": "sebastian/resource-operations", @@ -4140,16 +4179,16 @@ }, { "name": "symfony/polyfill-php80", - "version": "v1.31.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "60328e362d4c2c802a54fcbf04f9d3fb892b4cf8" + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/60328e362d4c2c802a54fcbf04f9d3fb892b4cf8", - "reference": "60328e362d4c2c802a54fcbf04f9d3fb892b4cf8", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", "shasum": "" }, "require": { @@ -4200,7 +4239,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0" }, "funding": [ { @@ -4211,12 +4250,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { "name": "symfony/polyfill-php81", @@ -4296,16 +4339,16 @@ }, { "name": "symfony/process", - "version": "v5.4.47", + "version": "v5.4.51", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "5d1662fb32ebc94f17ddb8d635454a776066733d" + "reference": "467bfc56f18f5ef6d5ccb09324d7e988c1c0a98f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/5d1662fb32ebc94f17ddb8d635454a776066733d", - "reference": "5d1662fb32ebc94f17ddb8d635454a776066733d", + "url": "https://api.github.com/repos/symfony/process/zipball/467bfc56f18f5ef6d5ccb09324d7e988c1c0a98f", + "reference": "467bfc56f18f5ef6d5ccb09324d7e988c1c0a98f", "shasum": "" }, "require": { @@ -4338,7 +4381,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v5.4.47" + "source": "https://github.com/symfony/process/tree/v5.4.51" }, "funding": [ { @@ -4349,12 +4392,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-11-06T11:36:42+00:00" + "time": "2026-01-26T15:53:37+00:00" }, { "name": "symfony/service-contracts", @@ -4589,16 +4636,16 @@ }, { "name": "theseer/tokenizer", - "version": "1.2.3", + "version": "1.3.1", "source": { "type": "git", "url": "https://github.com/theseer/tokenizer.git", - "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2" + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/theseer/tokenizer/zipball/737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", - "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", "shasum": "" }, "require": { @@ -4627,7 +4674,7 @@ "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", "support": { "issues": "https://github.com/theseer/tokenizer/issues", - "source": "https://github.com/theseer/tokenizer/tree/1.2.3" + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" }, "funding": [ { @@ -4635,15 +4682,15 @@ "type": "github" } ], - "time": "2024-03-03T12:36:25+00:00" + "time": "2025-11-17T20:03:58+00:00" } ], "aliases": [], "minimum-stability": "stable", - "stability-flags": [], + "stability-flags": {}, "prefer-stable": false, "prefer-lowest": false, - "platform": [], - "platform-dev": [], - "plugin-api-version": "2.6.0" + "platform": {}, + "platform-dev": {}, + "plugin-api-version": "2.9.0" } diff --git a/lib/Helpers/Arr.php b/lib/Helpers/Arr.php index c013f1e..cad2f36 100644 --- a/lib/Helpers/Arr.php +++ b/lib/Helpers/Arr.php @@ -23,10 +23,13 @@ public static function process($subject): ArrayProcessor /** * Applies the callback to the elements of the given array. * - * @param array $subject The array to apply the callback to. - * @param callable $callback The callback. + * @template TKey of array-key + * @template TValue + * @template TResult + * @param array $subject The array to apply the callback to. + * @param callable(TValue): TResult $callback The callback. * - * @return array + * @return array */ public static function map(array $subject, callable $callback): array { @@ -36,10 +39,12 @@ public static function map(array $subject, callable $callback): array /** * Applies the callback to the elements of the given array. * - * @param array $subject - * @param callable $callback - * @param mixed $initial - * @return mixed + * @template TValue + * @template TAccumulator + * @param array $subject + * @param callable(TAccumulator, TValue): TAccumulator $callback + * @param TAccumulator $initial + * @return TAccumulator */ public static function reduce(array $subject, callable $callback, $initial) { @@ -49,8 +54,8 @@ public static function reduce(array $subject, callable $callback, $initial) /** * Filters out items that are not null. * - * @param array $subject - * @return array + * @param array $subject + * @return array */ public static function whereNotNull(array $subject): array { @@ -60,8 +65,8 @@ public static function whereNotNull(array $subject): array /** * Filters out items that are not null. * - * @param array $subject - * @return array + * @param array $subject + * @return array */ public static function whereNotEmpty(array $subject): array { @@ -71,10 +76,10 @@ public static function whereNotEmpty(array $subject): array /** * Maps values, retaining keys if the array is associative. * - * @param array $subject + * @param array $subject * @param callable $callback * - * @return array + * @return array */ public static function each(array $subject, callable $callback): array { @@ -93,12 +98,12 @@ public static function each(array $subject, callable $callback): array /** * Gets a value, if set. Falls back to default otherwise. * - * @param array $subject - * @param string $key - * @param null $default + * @param array $subject + * @param array-key $key + * @param mixed $default * @return mixed */ - public static function get(array $subject, string $key, $default = null) + public static function get(array $subject, $key, $default = null) { if (isset($subject[$key])) { return $subject[$key]; @@ -110,10 +115,11 @@ public static function get(array $subject, string $key, $default = null) /** * Retrieve items after the specified array position. * - * @param array $subject the array + * @template TValue + * @param array $subject the array * @param int $position The position to retrieve after * - * @return array + * @return array */ public static function after(array $subject, int $position): array { @@ -123,14 +129,15 @@ public static function after(array $subject, int $position): array /** * Retrieve items before the specified array position. * - * @param array $subject the array + * @template TValue + * @param array $subject the array * @param int $position The position to retrieve before * - * @return array + * @return array */ public static function before(array $subject, int $position): array { - return Arr::diff(Arr::after($subject, $position), $subject); + return array_slice($subject, 0, $position); } /** @@ -140,10 +147,10 @@ public static function before(array $subject, int $position): array * current value from array is returned into * the result array. * - * @param array $subject The items to filter + * @param array $subject The items to filter * @param callable $callback * - * @return array + * @return array */ public static function filter(array $subject, callable $callback): array { @@ -153,9 +160,9 @@ public static function filter(array $subject, callable $callback): array /** * Finds a value based on a callback. Returns default value when not found. * - * @param array $subject + * @param array $subject * @param callable $callback - * @param $default + * @param mixed $default * @return mixed|null */ public static function find(array $subject, callable $callback, $default = null) @@ -170,6 +177,10 @@ public static function find(array $subject, callable $callback, $default = null) return $default; } + /** + * @param array $subject + * @return array-key|null + */ public static function findKey(array $subject, callable $callback) { foreach ($subject as $key => $item) { @@ -185,9 +196,9 @@ public static function findKey(array $subject, callable $callback) /** * Returns the values of the array. * - * @param array $subject + * @param array $subject * - * @return array + * @return list */ public static function values(array $subject): array { @@ -197,9 +208,9 @@ public static function values(array $subject): array /** * Returns the keys of the array. * - * @param array $subject + * @param array $subject * - * @return array + * @return list */ public static function keys(array $subject): array { @@ -209,19 +220,19 @@ public static function keys(array $subject): array /** * Retrieves an item from a dot-based syntax, returning the default value if not set. * - * @param array $subject + * @param array $subject * @param string $dot - * @param null $default + * @param mixed $default * @return mixed */ public static function dot(array $subject, string $dot, $default = null) { foreach (explode('.', $dot) as $item) { - if (!isset($subject[$item])) { + if (!is_array($subject) || !isset($subject[$item])) { return $default; - } else { - $subject = $subject[$item]; } + + $subject = $subject[$item]; } return $subject; @@ -235,7 +246,7 @@ public static function dot(array $subject, string $dot, $default = null) * is unset — so probing a deeper path than the data supports is safe, * never a TypeError. * - * @param array $subject + * @param array $subject * @param string $dot * @return bool */ @@ -255,22 +266,23 @@ public static function has(array $subject, string $dot): bool /** * Determines if all the specified set of values exist in the subject array. * - * @param array $subject - * @param $value - * @param ...$values + * @template TValue of scalar|\Stringable|null + * @param array $subject + * @param TValue $value + * @param TValue ...$values * @return bool */ public static function hasValues(array $subject, $value, ...$values): bool { - return !empty(Arr::intersect($subject, Arr::merge([$value], $values))); + return !empty(Arr::intersect($subject, array_merge([$value], $values))); } /** * Removes the specified item from the array, if it exists. * - * @param array $subject - * @param array-key $key - * @return array + * @param array $subject + * @param array-key|float $key PHP coerces float keys to integers. + * @return array */ public static function remove(array $subject, $key): array { @@ -284,9 +296,9 @@ public static function remove(array $subject, $key): array /** * Force an item to be an array, even if it is not an array. * - * @param $item mixed The item to force into an array + * @param mixed $item The item to force into an array * - * @return array + * @return array */ public static function wrap($item) { @@ -300,10 +312,10 @@ public static function wrap($item) /** * Create an array of new instances given arguments to pass * - * @param $array array The list of items to instantiate - * @param $instance string The instance to create + * @param array> $array The list of items to instantiate + * @param class-string $instance The instance to create * - * @return array + * @return list */ public static function hydrate(array $array, string $instance): array { @@ -332,9 +344,10 @@ public static function hydrate(array $array, string $instance): array * ['group' => 'group-2', 'key' => 'another-value', 'another' => 'value'] * ] * - * @param array $subject The array to flatten + * @param array> $subject The array to flatten * @param string $groupKey The key to use for the group identifier. * + * @return list> */ public static function flatten(array $subject, string $groupKey = 'group'): array { @@ -353,9 +366,9 @@ public static function flatten(array $subject, string $groupKey = 'group'): arra /** * Groups items in an array based on the group keys that all have matching values. * - * @param array $subject The array to group. + * @param array> $subject The array to group. * @param string ...$groupKeys The keys or property names to group by. - * @return array An array of arrays, each containing items grouped by the specified keys. + * @return list>> An array of grouped items. */ public static function group(array $subject, string ...$groupKeys): array { @@ -406,18 +419,18 @@ public static function group(array $subject, string ...$groupKeys): array /** * Updates the array to contain a key equal to the array's key value. * - * @param array $subject + * @param array $subject * @param string $key * @param string $valueKey * - * @return array + * @return list> */ public static function toIndexed(array $subject, string $key = 'key', string $valueKey = 'value'): array { $result = []; foreach ($subject as $subjectKey => $value) { - if (Arr::isAssociative(Arr::wrap($value))) { + if (is_array($value) && Arr::isAssociative($value)) { $result[] = Arr::merge([$key => $subjectKey], $value); } else { $result[] = Arr::merge([$key => $subjectKey], [$valueKey => $value]); @@ -430,9 +443,11 @@ public static function toIndexed(array $subject, string $key = 'key', string $va /** * Strips out duplicate items in the provided array. * - * @param array $subject + * @template TKey of array-key + * @template TValue of scalar|\Stringable|null + * @param array $subject * - * @return array + * @return array */ public static function unique(array $subject): array { @@ -442,7 +457,7 @@ public static function unique(array $subject): array /** * Sorts an array by the keys. * - * @param array $subject + * @param array $subject * * @return void */ @@ -454,20 +469,16 @@ public static function keySort(array &$subject): void /** * Check if the specified arrays contain the same data regardless of order. * - * @param array $input - * @param array ...$inputs + * @param array $input + * @param array ...$inputs * @return bool */ public static function containsSameData($input, ...$inputs): bool { - $inputs = Arr::merge([$input], $inputs); - $a = array_shift($inputs); - $a = static::normalize($a); - - while (!empty($inputs)) { - $b = array_shift($inputs); + $a = static::normalize($input); - $b = static::normalize($b); + foreach ($inputs as $inputToCompare) { + $b = static::normalize($inputToCompare); if ($a !== $b) { return false; @@ -480,7 +491,7 @@ public static function containsSameData($input, ...$inputs): bool /** * Sorts an array. * - * @param array $subject The item to sort + * @param array $subject The item to sort * @param callable|int $method The method. Can be any supported flag documented in PHP's asort, or a sorting * callback. * @param string $direction - can be "asc", "desc", or "rand" @@ -499,9 +510,9 @@ public static function sort(array &$subject, $method = SORT_REGULAR, string $dir /** * Merges arrays together. * - * @param array ...$args + * @param array ...$args * - * @return array + * @return array */ public static function merge(array ...$args): array { @@ -511,10 +522,10 @@ public static function merge(array ...$args): array /** * Reverses the order of the items in the array. * - * @param array $subject The input array. + * @param array $subject The input array. * @param bool $preserveKeys If set to true keys are preserved. * - * @return array + * @return array */ public static function reverse(array $subject, bool $preserveKeys = true): array { @@ -524,11 +535,11 @@ public static function reverse(array $subject, bool $preserveKeys = true): array /** * Recursively plucks values from a set of items. * - * @param object[]|array[] $items The list of items. + * @param array $items The list of items. * @param string $key The key that the value is set against. * @param mixed $default The default value to use when the value is not set. * - * @return array Array of values plucked from the list. + * @return array Array of values plucked from the list. */ public static function pluck(array $items, string $key, $default = null): array { @@ -541,7 +552,7 @@ public static function pluck(array $items, string $key, $default = null): array $result[$id] = $default; continue; } - } elseif (Arr::isAssociative($item)) { + } elseif (is_array($item) && Arr::isAssociative($item)) { $result[$id] = self::get($item, $key, $default); } elseif (is_array($item)) { $result[$id] = array_merge($result, self::pluck($item, $key, $default)); @@ -556,10 +567,10 @@ public static function pluck(array $items, string $key, $default = null): array /** * Cast all items in the array to the specified type. * - * @param array $items + * @param array $items * @param string $type * - * @return array + * @return list */ public static function cast(array $items, string $type): array { @@ -575,7 +586,7 @@ public static function cast(array $items, string $type): array /** * Returns true if this array is an associative array. * - * @param array $items + * @param array $items * * @return bool */ @@ -593,7 +604,7 @@ public static function isAssociative(array $items): bool /** * Adds items to the beginning of the array. * - * @param array $array array that the item should be prepended to. + * @param mixed $array Item that should be converted to an array and prepended to. * @param mixed ...$items Items to add * * @return void @@ -607,7 +618,7 @@ public static function prepend(&$array, ...$items): void /** * Adds items to the end of the array. * - * @param array $array array that the item should be prepended to. + * @param mixed $array Item that should be converted to an array and appended to. * @param mixed ...$items Items to add * * @return void @@ -620,7 +631,11 @@ public static function append(&$array, ...$items): void } } - public static function flip($array): array + /** + * @param array $array + * @return array + */ + public static function flip(array $array): array { return array_flip($array); } @@ -628,12 +643,12 @@ public static function flip($array): array /** * Recursively sorts, and optionally mutates an array of arrays. * - * @param array $array The array to sort. + * @param array $array The array to sort. * - * @type bool $convertClosures If true, closures will be converted to an identifiable string. Default true. - * @type bool $recursive if true, this function will normalize recursively, manipulating sub-arrays. + * @param bool $convertClosures If true, closures will be converted to an identifiable string. Default true. + * @param bool $recursive if true, this function will normalize recursively, manipulating sub-arrays. * - * @return array The normalized array + * @return array The normalized array * @throws \ReflectionException */ public static function normalize(array $array, $convertClosures = true, $recursive = true): array @@ -642,9 +657,7 @@ public static function normalize(array $array, $convertClosures = true, $recursi foreach ($array as $key => $value) { // Normalize recursively. if (is_array($value) && true === $recursive) { - $args = func_get_args(); - $args[0] = $value; - $array[$key] = self::normalize(...$args); + $array[$key] = self::normalize($value, $convertClosures, $recursive); } // If closures need converted, and this is a closure, transform this into an identifiable string. @@ -667,21 +680,23 @@ public static function normalize(array $array, $convertClosures = true, $recursi /** * Returns an array that contains the values contained in all arrays. * - * @param array ...$items + * @template TKey of array-key + * @template TValue of scalar|\Stringable|null + * @param array ...$items * - * @return array + * @return array */ public static function intersect(array ...$items): array { - return array_intersect(...self::map(func_get_args(), [Arr::class, 'wrap'])); + return array_intersect(...array_values($items)); } /** * Returns an array that contains the values contained in all arrays. * - * @param array ...$items + * @param array ...$items * - * @return array + * @return array */ public static function intersectKeys(array ...$items): array { @@ -691,62 +706,72 @@ public static function intersectKeys(array ...$items): array /** * Returns an array that contains values only contained in a single array. * - * @param array ...$items + * @template TKey of array-key + * @template TValue of scalar|\Stringable|null + * @param array ...$items * - * @return array + * @return array */ public static function diff(array ...$items): array { - return array_diff(...self::map(func_get_args(), [Arr::class, 'wrap'])); + return array_diff(...array_values($items)); } /** * Combines arrays into a single array, with each item overriding items from the previous array. * - * @param array ...$items + * @param array ...$items * - * @return array + * @return array */ public static function replaceRecursive(array ...$items): array { - return array_replace_recursive($items); + if ($items === []) { + return []; + } + + return array_replace_recursive(...array_values($items)); } /** * Combines arrays into a single array, with each item overriding items from the previous array. * - * @param array ...$items + * @param array ...$items * - * @return array + * @return array */ public static function replace(array ...$items): array { - return array_replace($items); + if ($items === []) { + return []; + } + + return array_replace(...array_values($items)); } /** * Gets the first value from the array. * - * @param array $items - * @param null $default + * @param array $items + * @param mixed $default * @return mixed */ public static function first(array $items, $default = null) { - return Arr::get(array_values($items), 0, $default); + return Arr::get(array_values($items), '0', $default); } /** * Gets the first value from the array. * - * @param array $items - * @param null $default + * @param array $items + * @param mixed $default * @return mixed */ public static function last(array $items, $default = null) { $items = array_values($items); - return Arr::get($items, count($items) - 1, $default); + return Arr::get($items, (string) (count($items) - 1), $default); } } diff --git a/lib/Helpers/ClosureAdapter.php b/lib/Helpers/ClosureAdapter.php index 2ecb15d..400ddcf 100644 --- a/lib/Helpers/ClosureAdapter.php +++ b/lib/Helpers/ClosureAdapter.php @@ -14,13 +14,22 @@ class ClosureAdapter * * @param Closure $data * - * @return array + * @return array{string, array} * @throws ReflectionException */ public static function getClosureData(Closure $data): array { $ref = new ReflectionFunction($data); - $file = new SplFileObject($ref->getFileName()); + $filename = $ref->getFileName(); + if ($filename === false) { + throw new ReflectionException('Cannot read source for an internal closure.'); + } + + try { + $file = new SplFileObject($filename); + } catch (\RuntimeException $e) { + throw new ReflectionException('Cannot open closure source file.', 0, $e); + } $file->seek($ref->getStartLine() - 1); $content = ''; while ($file->key() < $ref->getEndLine()) { diff --git a/lib/Helpers/Num.php b/lib/Helpers/Num.php index f06800f..6eb7449 100644 --- a/lib/Helpers/Num.php +++ b/lib/Helpers/Num.php @@ -19,7 +19,7 @@ public static function calculatePercentage(int $amount, int $percent): float * * @param numeric $amount * @param numeric $dividedBy - * @param int $mode + * @param 1|2|3|4 $mode * @return int */ public static function getDividedInt($amount, $dividedBy, int $mode = PHP_ROUND_HALF_UP): int @@ -29,8 +29,9 @@ public static function getDividedInt($amount, $dividedBy, int $mode = PHP_ROUND_ return static::floatToInt($product, $mode); } + /** @param 1|2|3|4 $mode */ public static function floatToInt(float $input, int $mode = PHP_ROUND_HALF_UP): int { return (int) round($input, 0, $mode); } -} \ No newline at end of file +} diff --git a/lib/Helpers/Obj.php b/lib/Helpers/Obj.php index 59fa4d9..b90e93c 100644 --- a/lib/Helpers/Obj.php +++ b/lib/Helpers/Obj.php @@ -2,38 +2,60 @@ namespace PHPNomad\Utils\Helpers; -use Exception; use PHPNomad\Core\Exceptions\ItemNotFound; +use Throwable; class Obj { /** * Gets a field from an object. Attempts to call get_field, and the fields accessor. * + * @return mixed * @throws ItemNotFound */ public static function pluck(object $value, string $fields) { $fields = explode('.', $fields); foreach ($fields as $field) { + if (!is_object($value)) { + throw new ItemNotFound("Cannot read field '$field' from a non-object value."); + } + $name = ucfirst($field); + $camelGetter = "get$name"; + $snakeGetter = "get_$field"; // Bail early if this field is not in this object. - if (is_callable([$value, "get$name"])) { - $value = call_user_func([$value, "get$name"]); - } elseif (is_callable([$value, "get_$field"])) { - $value = call_user_func([$value, "get_$field"]); - } else { + if (is_callable([$value, $camelGetter])) { + $value = $value->{$camelGetter}(); + } elseif (is_callable([$value, $snakeGetter])) { + $value = $value->{$snakeGetter}(); + } elseif (array_key_exists($field, get_object_vars($value))) { try { - $value = $value->$field; - } catch (Exception $e) { - throw new ItemNotFound(); + $value = self::readPublicProperty($value, $field); + } catch (Throwable $e) { + throw new ItemNotFound("Cannot read field '$field'.", 0, $e); } + } elseif (method_exists($value, '__get')) { + $value = $value->$field; + } elseif (property_exists($value, $field)) { + throw new ItemNotFound("Cannot read inaccessible or uninitialized field '$field'."); + } else { + throw new ItemNotFound("Field '$field' was not found."); } } return $value; } + /** + * @return mixed + * @throws Throwable + */ + private static function readPublicProperty(object $value, string $field) + { + return $value->$field; + } + /** * @param class-string $instance * @param class-string $implements @@ -43,9 +65,12 @@ public static function pluck(object $value, string $fields) public static function implements(string $instance, string $implements, string ...$moreImplements): bool { $items = class_implements($instance); - $implements = Arr::merge([$implements], $moreImplements); + if ($items === false) { + throw new \TypeError('Unable to inspect implemented interfaces.'); + } + $implements = array_merge([$implements], $moreImplements); $test = Arr::intersect($items, $implements); return count($test) === count($implements); } -} \ No newline at end of file +} diff --git a/lib/Helpers/Str.php b/lib/Helpers/Str.php index 5fd9c15..ad27fb8 100644 --- a/lib/Helpers/Str.php +++ b/lib/Helpers/Str.php @@ -10,9 +10,9 @@ class Str /** * Makes a word plural. * - * @param $singular - * @param $count - * @param $plural + * @param string $singular + * @param int|float $count + * @param string $plural * @return string */ public static function pluarize($singular, $count, $plural = 's'): string @@ -38,7 +38,7 @@ public static function camelCase(string $subject): string public static function camelCaseToDashCase(string $subject): string { - $subject = preg_replace('/[A-Z]/', '-$0', $subject); + $subject = preg_replace('/[A-Z]/', '-$0', $subject) ?? $subject; $subject = strtolower($subject); return ltrim($subject, '-'); @@ -70,6 +70,11 @@ public static function pascalCase(string $subject): string */ public static function createHash($data, ?string $key = null): string { + // Convert closures before the generic object branch erases their source and captures. + if ($data instanceof Closure) { + $data = ClosureAdapter::getClosureData($data); + } + // If object, convert to array. if (is_object($data)) { $data = (array)$data; @@ -80,11 +85,6 @@ public static function createHash($data, ?string $key = null): string $data = Arr::normalize($data); } - // Convert closures - if ($data instanceof Closure) { - $data = ClosureAdapter::getClosureData($data); - } - if (!$key) { return hash('md5', serialize($data)); } else { @@ -92,6 +92,10 @@ public static function createHash($data, ?string $key = null): string } } + /** + * @param string $haystack + * @param string $needle + */ public static function contains($haystack, $needle): bool { return strpos($haystack, $needle) !== false; @@ -199,8 +203,19 @@ public static function prepend(string $subject, string $prepend): string return $prepend . $subject; } + /** + * @param mixed $subject + * @param mixed $divider + */ public static function basename($subject, $divider = '/'): ?string { + if (!is_string($subject) || !is_string($divider)) { + return null; + } + if ($divider === '') { + return null; + } + $items = explode($divider, $subject); return array_pop($items); @@ -254,6 +269,7 @@ public static function before(string $subject, string $before = ' '): string return substr($subject, 0, $pos); } + /** @param mixed ...$args */ public static function getBuffer(callable $callback, ...$args): string { ob_start(); @@ -264,7 +280,7 @@ public static function getBuffer(callable $callback, ...$args): string /** * Join an array of items into a string using a specified conjunction. * - * @param array $enumerableItems Array of items to be enumerated + * @param array $enumerableItems Array of items to be enumerated * @param string $conjunction Conjunction used for joining the items (default: 'and') * * @return string Joined string with enumerated items @@ -275,7 +291,7 @@ public static function enumerate(array $enumerableItems, string $conjunction = ' return ''; } - $enumerableItems = Arr::cast($enumerableItems, 'string'); + $enumerableItems = self::stringifyItems($enumerableItems); $lastItem = array_pop($enumerableItems); $result = $enumerableItems ? implode(', ', $enumerableItems) . ", $conjunction " . $lastItem : $lastItem; @@ -286,4 +302,19 @@ public static function enumerate(array $enumerableItems, string $conjunction = ' return $result; } + + /** + * @param non-empty-array $items + * @return non-empty-list + */ + private static function stringifyItems(array $items): array + { + $strings = []; + foreach ($items as $item) { + settype($item, 'string'); + $strings[] = $item; + } + + return $strings; + } } diff --git a/lib/Processors/ArrayProcessor.php b/lib/Processors/ArrayProcessor.php index 50980e4..8733103 100644 --- a/lib/Processors/ArrayProcessor.php +++ b/lib/Processors/ArrayProcessor.php @@ -2,20 +2,27 @@ namespace PHPNomad\Utils\Processors; +use InvalidArgumentException; use PHPNomad\Utils\Helpers\Arr; use ReflectionException; final class ArrayProcessor { + /** @var array */ protected array $subject = []; private string $separator = ','; + /** @param array $subject */ public function __construct(array $subject = []) { $this->subject = $subject; } + /** + * @param mixed $default + * @return $this + */ public function pluck(string $key, $default = null) { $this->subject = Arr::pluck($this->subject, $key, $default); @@ -24,7 +31,7 @@ public function pluck(string $key, $default = null) } /** - * @return array + * @return array */ public function toArray(): array { @@ -48,7 +55,7 @@ public function count(): int */ public function flip(): ArrayProcessor { - $this->subject = Arr::flip($this->subject); + $this->subject = Arr::flip($this->getFlippableSubject()); return $this; } @@ -98,13 +105,13 @@ public function remove($key): ArrayProcessor /** * Create an array of new instances given arguments to pass * - * @param $instance string The instance to create + * @param class-string $instance The instance to create * * @return $this */ public function hydrate(string $instance): ArrayProcessor { - $this->subject = Arr::hydrate($this->subject, $instance); + $this->subject = Arr::hydrate($this->getHydrationSubject(), $instance); return $this; } @@ -131,14 +138,14 @@ public function hydrate(string $instance): ArrayProcessor */ public function flatten(string $groupKey = 'group'): ArrayProcessor { - $this->subject = Arr::flatten($this->subject, $groupKey); + $this->subject = Arr::flatten($this->getFlattenableSubject(), $groupKey); return $this; } public function group(string ...$groups): ArrayProcessor { - $this->subject = Arr::group($this->subject, ...$groups); + $this->subject = Arr::group($this->getGroupableSubject(), ...$groups); return $this; } @@ -157,7 +164,7 @@ public function toIndexed(string $key = 'key'): ArrayProcessor */ public function unique(): ArrayProcessor { - $this->subject = Arr::unique($this->subject); + $this->subject = Arr::unique($this->getComparableSubject()); return $this; } @@ -165,7 +172,7 @@ public function unique(): ArrayProcessor /** * Merges the provided arrays with the array that is being processed. * - * @param array ...$defaults + * @param array ...$defaults * * @return $this */ @@ -179,7 +186,7 @@ public function merge(array ...$defaults): ArrayProcessor /** * Combines arrays into a single array, with each item overriding items from the previous array. * - * @param array ...$items + * @param array ...$items * * @return $this */ @@ -193,7 +200,7 @@ public function replaceRecursive(array ...$items): ArrayProcessor /** * Combines arrays into a single array, with each item overriding items from the previous array. * - * @param array ...$items + * @param array ...$items * * @return $this */ @@ -236,8 +243,8 @@ public function append(...$items): ArrayProcessor /** * Recursively sorts, and optionally mutates an array of arrays. * - * @type bool $convertClosures If true, closures will be converted to an identifiable string. Default true. - * @type bool $recursive if true, this function will normalize recursively, manipulating sub-arrays. + * @param bool $convertClosures If true, closures will be converted to an identifiable string. Default true. + * @param bool $recursive if true, this function will normalize recursively, manipulating sub-arrays. * * @throws ReflectionException */ @@ -290,8 +297,8 @@ public function map(callable $callback): ArrayProcessor } /** - * @param callable $callback - * @param mixed $initial + * @param callable(array, mixed): array $callback + * @param array $initial * * @return $this */ @@ -363,13 +370,16 @@ public function cast(string $type): ArrayProcessor /** * Filters the array to only contain values contained in all provided arrays. * - * @param array ...$items + * @param array ...$items * * @return static */ public function intersect(array ...$items): ArrayProcessor { - $this->subject = Arr::intersect($this->subject, ...$items); + $this->subject = Arr::intersect( + $this->getComparableSubject(), + ...$this->getComparableArrays($items) + ); return $this; } @@ -377,7 +387,7 @@ public function intersect(array ...$items): ArrayProcessor /** * Filters the array to only contain values contained in all provided arrays. * - * @param array ...$items + * @param array ...$items * * @return static */ @@ -391,13 +401,16 @@ public function intersectKeys(array ...$items): ArrayProcessor /** * Filters the array to only contain values only contained in a single array. * - * @param array ...$items + * @param array> $items * * @return static */ public function diff(...$items): ArrayProcessor { - $this->subject = Arr::diff($this->subject, ...$items); + $this->subject = Arr::diff( + $this->getComparableSubject(), + ...$this->getComparableArrays($items) + ); return $this; } @@ -430,4 +443,111 @@ public function toString(): string { return implode($this->separator, $this->subject); } + + /** @return array */ + private function getFlippableSubject(): array + { + $subject = []; + foreach ($this->subject as $key => $item) { + if (!is_int($item) && !is_string($item)) { + throw new InvalidArgumentException('Flipping requires every subject item to be an integer or string.'); + } + $subject[$key] = $item; + } + + return $subject; + } + + /** @return array> */ + private function getHydrationSubject(): array + { + $subject = []; + foreach ($this->subject as $key => $item) { + if (!is_array($item)) { + throw new InvalidArgumentException('Hydration requires every subject item to be an array.'); + } + $subject[$key] = $item; + } + + return $subject; + } + + /** @return array> */ + private function getFlattenableSubject(): array + { + $subject = []; + foreach ($this->subject as $key => $item) { + if (!is_array($item)) { + throw new InvalidArgumentException('Flattening requires every subject item to be an array.'); + } + $subject[$key] = $item; + } + + return $subject; + } + + /** @return array|object> */ + private function getGroupableSubject(): array + { + $subject = []; + foreach ($this->subject as $key => $item) { + if (!is_array($item) && !is_object($item)) { + throw new InvalidArgumentException('Grouping requires every subject item to be an array or object.'); + } + $subject[$key] = $item; + } + + return $subject; + } + + /** @return array */ + private function getComparableSubject(): array + { + return $this->getComparableValues($this->subject); + } + + /** + * @param array $values + * @return array + */ + private function getComparableValues(array $values): array + { + $comparable = []; + foreach ($values as $key => $value) { + if (!is_scalar($value) && $value !== null && !$this->isStringableValue($value)) { + throw new InvalidArgumentException( + 'Array comparison requires every value to be scalar, null, or stringable.' + ); + } + $comparable[$key] = $value; + } + + return $comparable; + } + + /** + * @param mixed $value + * @phpstan-assert-if-true \Stringable $value + */ + private function isStringableValue($value): bool + { + return is_object($value) && method_exists($value, '__toString'); + } + + /** + * @param array $items + * @return list> + */ + private function getComparableArrays(array $items): array + { + $comparable = []; + foreach ($items as $item) { + if (!is_array($item)) { + throw new InvalidArgumentException('Array comparison requires arrays of comparable values.'); + } + $comparable[] = $this->getComparableValues($item); + } + + return $comparable; + } } diff --git a/lib/Processors/ListFilter.php b/lib/Processors/ListFilter.php index 1f7b046..b25beea 100644 --- a/lib/Processors/ListFilter.php +++ b/lib/Processors/ListFilter.php @@ -2,18 +2,24 @@ namespace PHPNomad\Utils\Processors; +use InvalidArgumentException; use PHPNomad\Core\Exceptions\ItemNotFound; use PHPNomad\Utils\Helpers\Arr; use PHPNomad\Utils\Helpers\Obj; +/** @phpstan-consistent-constructor */ class ListFilter { + /** @var array */ protected array $filter_args = []; + + /** @var array */ protected array $items; protected ?int $limit = null; protected ?int $offset = null; + /** @param array $items */ public function __construct(array $items) { $this->items = $items; @@ -82,7 +88,7 @@ public function filterFromCallback(string $field, callable $callback) /** * Sets the query to only include items that are not an of the provided instances. * - * @param array $values The values to filter. + * @param class-string ...$values The values to filter. * * @return $this */ @@ -96,7 +102,7 @@ public function notInstanceOf(...$values) /** * Sets the query to only include items that are an instance of all the provided instances. * - * @param array $values The values to filter. + * @param class-string ...$values The values to filter. * * @return $this */ @@ -110,7 +116,7 @@ public function hasAllInstances(...$values) /** * Sets the query to only include items that are instance of any the provided instances. * - * @param array $values The values to filter. + * @param class-string ...$values The values to filter. * * @return $this */ @@ -139,7 +145,7 @@ public function instanceOf(string $value) * Sets the query to filter out items whose field has any of the provided values. * * @param string $field The field to check against. - * @param array $values The values to filter. + * @param scalar|\Stringable|null ...$values The values to filter. * * @return $this */ @@ -154,7 +160,7 @@ public function notIn(string $field, ...$values) * Sets the query to filter out items whose field does not have all the provided values. * * @param string $field The field to check against. - * @param array $values The values to filter. + * @param scalar|\Stringable|null ...$values The values to filter. * * @return $this */ @@ -169,7 +175,7 @@ public function and(string $field, ...$values) * Sets the query to filter out items whose field does not have any of the provided values. * * @param string $field The field to check against. - * @param array $values The values to filter. + * @param scalar|\Stringable|null ...$values The values to filter. * * @return $this */ @@ -185,7 +191,7 @@ public function in(string $field, ...$values) * Sets the query to filter out items whose value is not identical to the provided value. * * @param string $field The field to check against. - * @param mixed $value The value to check. + * @param scalar|\Stringable|null $value The value to check. * * @return $this */ @@ -199,7 +205,7 @@ public function equals(string $field, $value) /** * Sets the query to filter out items whose key has any of the provided values. * - * @param array $values The values to filter. + * @param array-key ...$values The values to filter. * * @return $this */ @@ -213,7 +219,7 @@ public function keyNotIn(...$values) /** * Sets the query to filter out items whose key does not have all the provided values. * - * @param array $values The values to filter. + * @param array-key ...$values The values to filter. * * @return $this */ @@ -225,9 +231,9 @@ public function keyIn(...$values) } /** - * @param $key + * @param string $key * - * @return array + * @return array{field: string, type: string} */ protected function prepareField($key): array { @@ -272,10 +278,16 @@ protected function filterItem(object $item): ?object } if ($type === 'callback') { + if (!is_callable($arg)) { + throw new InvalidArgumentException("Filter '$key' must contain a callable value."); + } $valid = $arg($value); } else { - $fields = Arr::intersect(Arr::wrap($arg), Arr::wrap($value)); + $fields = Arr::intersect( + $this->getComparableValues(Arr::wrap($arg), $key), + $this->getComparableValues(Arr::wrap($value), $key) + ); switch ($type) { case 'not_in': @@ -285,6 +297,9 @@ protected function filterItem(object $item): ?object $valid = !empty($fields); break; case 'and': + if (!is_array($arg)) { + throw new InvalidArgumentException("Filter '$key' must contain an array of values."); + } $valid = count($fields) === count($arg); break; case 'equals': @@ -321,7 +336,7 @@ protected function filterItem(object $item): ?object /** * Pre-filters the list of items. * - * @return array + * @return array */ protected function filterItemKeys(): array { @@ -329,30 +344,114 @@ protected function filterItemKeys(): array // Filter out keys, if keys are specified if (isset($this->filter_args['filter_enum_key__in'])) { - $items = Arr::intersect($items, $this->filter_args['filter_enum_key__in']); + $includedKeys = $this->filter_args['filter_enum_key__in']; + if (!$this->isArrayOfKeys($includedKeys)) { + throw new InvalidArgumentException('Key inclusion filters must contain an array of integer or string keys.'); + } + $items = $this->keepMatchingKeys($items, $includedKeys); unset($this->filter_args['filter_enum_key__in']); } if (isset($this->filter_args['filter_enum_key__not_in'])) { - $items = Arr::diff($items, $this->filter_args['filter_enum_key__not_in']); + $excludedKeys = $this->filter_args['filter_enum_key__not_in']; + if (!$this->isArrayOfKeys($excludedKeys)) { + throw new InvalidArgumentException('Key exclusion filters must contain an array of integer or string keys.'); + } + $items = $this->removeMatchingKeys($items, $excludedKeys); unset($this->filter_args['filter_enum_key__not_in']); } return $items; } + /** + * @param mixed $value + * @phpstan-assert-if-true array $value + */ + private function isArrayOfKeys($value): bool + { + if (!is_array($value)) { + return false; + } + + foreach ($value as $item) { + if (!is_int($item) && !is_string($item)) { + return false; + } + } + + return true; + } + + /** + * @param array $items + * @param array $includedKeys + * @return array + */ + private function keepMatchingKeys(array $items, array $includedKeys): array + { + $result = []; + foreach (Arr::intersect($items, $includedKeys) as $key => $item) { + $result[$key] = $item; + } + + return $result; + } + + /** + * @param array $items + * @param array $excludedKeys + * @return array + */ + private function removeMatchingKeys(array $items, array $excludedKeys): array + { + $result = []; + foreach (Arr::diff($items, $excludedKeys) as $key => $item) { + $result[$key] = $item; + } + + return $result; + } + + /** + * @param array $values + * @return array + */ + private function getComparableValues(array $values, string $filter): array + { + $comparable = []; + foreach ($values as $key => $value) { + if (!is_scalar($value) && $value !== null && !$this->isStringableValue($value)) { + throw new InvalidArgumentException( + "Filter '$filter' must contain only scalar, null, or stringable values." + ); + } + $comparable[$key] = $value; + } + + return $comparable; + } + + /** + * @param mixed $value + * @phpstan-assert-if-true \Stringable $value + */ + private function isStringableValue($value): bool + { + return is_object($value) && method_exists($value, '__toString'); + } + /** * Queries a loader registry. * - * @return object[] Array of registry items. + * @return array Array of registry items. */ public function filter(): array { $results = []; - $actualLimit = $this->limit ?? 0 + $this->offset ?? 0; - - if(!$actualLimit){ - $actualLimit = null; + $actualLimit = null; + if ($this->limit !== null) { + $actualLimit = $this->limit + ($this->offset ?? 0); } foreach ($this->filterItemKeys() as $item_key) { @@ -366,13 +465,13 @@ public function filter(): array $results[$item_key] = $item; } - if($actualLimit === count($results)){ + if ($actualLimit !== null && $actualLimit === count($results)) { break; } } - if(!is_null($this->offset)){ - $results = Arr::after($results, $this->offset - 1); + if ($this->offset !== null) { + $results = Arr::after($results, $this->offset); } return $results; @@ -381,35 +480,49 @@ public function filter(): array /** * Stops after reaching the specified number of results. * - * @param ?positive-int $limit + * @param int|null $limit Must be positive when provided. * @return $this + * @throws InvalidArgumentException */ public function limit(?int $limit = null) { + if ($limit !== null && $limit < 1) { + throw new InvalidArgumentException('Limit must be null or a positive integer.'); + } + $this->limit = $limit; return $this; } /** - * @param int|null $offset + * @param int|null $offset Must not be negative when provided. * @return $this + * @throws InvalidArgumentException */ public function offset(?int $offset = null) { + if ($offset !== null && $offset < 0) { + throw new InvalidArgumentException('Offset must be null or a non-negative integer.'); + } + $this->offset = $offset; return $this; } /** - * @return array + * @return array */ public function getFilterArgs(): array { return $this->filter_args; } + /** + * @param array $filterArgs + * @param array $inputData + */ public static function seed(array $filterArgs, array $inputData): ListFilter { $new = new static($inputData); diff --git a/tests/Unit/ArrBeforeTest.php b/tests/Unit/ArrBeforeTest.php new file mode 100644 index 0000000..3ebe726 --- /dev/null +++ b/tests/Unit/ArrBeforeTest.php @@ -0,0 +1,20 @@ + true]; + $second = new \stdClass(); + + $this->assertSame( + ['first' => $first, 'second' => $second], + Arr::before(['first' => $first, 'second' => $second, 'third' => 3], 2) + ); + } +} diff --git a/tests/Unit/ArrTest.php b/tests/Unit/ArrTest.php index c65bdd2..c30cd18 100644 --- a/tests/Unit/ArrTest.php +++ b/tests/Unit/ArrTest.php @@ -7,14 +7,122 @@ class ArrTest extends TestCase { + public function testGetSupportsStringAndIntegerKeysWithoutANarrowNativeType(): void + { + $subject = [0 => 'first', 'name' => 'value']; + + $this->assertSame('first', Arr::get($subject, 0)); + $this->assertSame('value', Arr::get($subject, 'name')); + + $key = (new \ReflectionMethod(Arr::class, 'get'))->getParameters()[1]; + $this->assertFalse($key->hasType()); + } + + public function testDotReturnsDefaultWhenAnIntermediateValueIsNotAnArray(): void + { + $this->assertSame( + 'missing', + Arr::dot(['user' => 'alex'], 'user.name', 'missing') + ); + $this->assertSame( + 'missing', + Arr::dot(['user' => 42], 'user.name', 'missing') + ); + } + + public function testReplaceCombinesArraysWithoutNestingTheArgumentList(): void + { + $this->assertSame( + ['settings' => ['color' => 'red'], 'enabled' => true], + Arr::replace( + ['settings' => ['color' => 'blue', 'size' => 'm'], 'enabled' => true], + ['settings' => ['color' => 'red']] + ) + ); + } + + public function testReplaceRecursiveCombinesNestedArrays(): void + { + $this->assertSame( + ['settings' => ['color' => 'red', 'size' => 'm'], 'enabled' => true], + Arr::replaceRecursive( + ['settings' => ['color' => 'blue', 'size' => 'm'], 'enabled' => true], + ['settings' => ['color' => 'red']] + ) + ); + } + + public function testReplaceMethodsReturnAnEmptyArrayWithoutInputs(): void + { + $this->assertSame([], Arr::replace()); + $this->assertSame([], Arr::replaceRecursive()); + } + + public function testReplaceMethodsHonorNamedVariadicCallOrder(): void + { + $first = ['settings' => ['color' => 'blue', 'size' => 'm'], 'enabled' => true]; + $second = ['settings' => ['color' => 'red']]; + + if (PHP_VERSION_ID >= 80000) { + $replace = eval( + 'return \\PHPNomad\\Utils\\Helpers\\Arr::replace(first: $first, second: $second);' + ); + $replaceRecursive = eval( + 'return \\PHPNomad\\Utils\\Helpers\\Arr::replaceRecursive(first: $first, second: $second);' + ); + } else { + $replace = Arr::replace($first, $second); + $replaceRecursive = Arr::replaceRecursive($first, $second); + } + + $this->assertSame( + ['settings' => ['color' => 'red'], 'enabled' => true], + $replace + ); + $this->assertSame( + ['settings' => ['color' => 'red', 'size' => 'm'], 'enabled' => true], + $replaceRecursive + ); + } + + public function testComparableArrayHelpersPreserveKeysAndValues(): void + { + $first = new ArrStringableValue('first'); + $duplicate = new ArrStringableValue('first'); + $second = new ArrStringableValue('second'); + + $this->assertSame( + ['first' => $first, 'second' => $second], + Arr::unique(['first' => $first, 'duplicate' => $duplicate, 'second' => $second]) + ); + $this->assertSame( + ['first' => $first], + Arr::intersect(['first' => $first, 'second' => $second], [$duplicate]) + ); + $this->assertSame( + ['second' => $second], + Arr::diff(['first' => $first, 'second' => $second], [$duplicate]) + ); + } + + public function testPluckUsesTheDefaultForScalarItems(): void + { + $this->assertSame( + ['missing', 'found'], + Arr::pluck([42, ['name' => 'found']], 'name', 'missing') + ); + } + /** * @dataProvider hasDottedPathCases + * @param array $subject */ public function testHasWalksDottedPaths(array $subject, string $dot, bool $expected): void { $this->assertSame($expected, Arr::has($subject, $dot)); } + /** @return iterable, string, bool}> */ public static function hasDottedPathCases(): iterable { yield 'single key present' => [ @@ -81,3 +189,18 @@ public static function hasDottedPathCases(): iterable ]; } } + +final class ArrStringableValue +{ + private string $value; + + public function __construct(string $value) + { + $this->value = $value; + } + + public function __toString(): string + { + return $this->value; + } +} diff --git a/tests/Unit/ArrayProcessorTest.php b/tests/Unit/ArrayProcessorTest.php new file mode 100644 index 0000000..343666b --- /dev/null +++ b/tests/Unit/ArrayProcessorTest.php @@ -0,0 +1,207 @@ + 'one', 'second' => 2]); + + $returned = $processor->flip(); + + $this->assertSame($processor, $returned); + $this->assertSame(['one' => 'first', 2 => 'second'], $processor->toArray()); + } + + public function testFlipRejectsInvalidItemsBeforeMutation(): void + { + $invalid = new \stdClass(); + $processor = new ArrayProcessor(['valid', $invalid]); + + $this->assertInvalidOperationLeavesSubject( + $processor, + static function () use ($processor): void { + $processor->flip(); + }, + ['valid', $invalid] + ); + } + + public function testHydrateRejectsInvalidItemsBeforeMutation(): void + { + $processor = new ArrayProcessor([['value'], 42]); + + $this->assertInvalidOperationLeavesSubject( + $processor, + static function () use ($processor): void { + $processor->hydrate(ArrayProcessorFixture::class); + }, + [['value'], 42] + ); + } + + public function testFlattenRejectsInvalidGroupsBeforeMutation(): void + { + $processor = new ArrayProcessor([[['value' => 1]], 42]); + + $this->assertInvalidOperationLeavesSubject( + $processor, + static function () use ($processor): void { + $processor->flatten(); + }, + [[['value' => 1]], 42] + ); + } + + public function testGroupRejectsInvalidItemsBeforeMutation(): void + { + $processor = new ArrayProcessor([['value' => 1], 42]); + + $this->assertInvalidOperationLeavesSubject( + $processor, + static function () use ($processor): void { + $processor->group('value'); + }, + [['value' => 1], 42] + ); + } + + public function testReduceKeepsAnArrayAccumulatorInTheFluentProcessor(): void + { + $processor = new ArrayProcessor([ + ['id' => 10], + ['id' => 20], + ]); + + $returned = $processor->reduce( + static function (array $ids, $item): array { + if (!is_array($item) || !is_int($item['id'] ?? null)) { + throw new \UnexpectedValueException('Expected an integer ID.'); + } + $ids[] = $item['id']; + + return $ids; + }, + [] + ); + + $this->assertSame($processor, $returned); + $this->assertSame([10, 20], $processor->toArray()); + } + + public function testComparableOperationsPreserveSupportedValues(): void + { + $stringable = new ArrayProcessorStringableValue('object'); + $values = [false, 1.5, 2, 'three', null, $stringable]; + + foreach ($values as $value) { + $unique = new ArrayProcessor([$value]); + $this->assertSame($unique, $unique->unique()); + $this->assertSame([$value], $unique->toArray()); + } + + $nativeCollision = new ArrayProcessor([false, null]); + $nativeCollision->unique(); + $this->assertSame([false], $nativeCollision->toArray()); + + $intersect = new ArrayProcessor($values); + $this->assertSame($intersect, $intersect->intersect($values)); + $this->assertSame($values, $intersect->toArray()); + + $diff = new ArrayProcessor($values); + $this->assertSame($diff, $diff->diff([])); + $this->assertSame($values, $diff->toArray()); + } + + /** @dataProvider invalidComparableOperations */ + public function testComparableOperationsRejectInvalidSubjectsBeforeMutation(string $operation): void + { + $processor = new ArrayProcessor(['valid', ['invalid']]); + + $this->assertInvalidOperationLeavesSubject( + $processor, + static function () use ($processor, $operation): void { + $processor->$operation(['valid']); + }, + ['valid', ['invalid']] + ); + } + + /** @return iterable */ + public static function invalidComparableOperations(): iterable + { + yield 'unique' => ['unique']; + yield 'intersect' => ['intersect']; + yield 'diff' => ['diff']; + } + + /** @dataProvider comparableOperationsWithInputs */ + public function testComparableOperationsRejectInvalidInputsBeforeMutation(string $operation): void + { + $processor = new ArrayProcessor(['valid']); + + $this->assertInvalidOperationLeavesSubject( + $processor, + static function () use ($processor, $operation): void { + $processor->$operation([['invalid']]); + }, + ['valid'] + ); + } + + /** @return iterable */ + public static function comparableOperationsWithInputs(): iterable + { + yield 'intersect' => ['intersect']; + yield 'diff' => ['diff']; + } + + /** + * @param callable(): void $operation + * @param array $expected + */ + private function assertInvalidOperationLeavesSubject( + ArrayProcessor $processor, + callable $operation, + array $expected + ): void { + try { + $operation(); + $this->fail('Expected invalid processor input to be rejected.'); + } catch (InvalidArgumentException $e) { + $this->assertNotSame('', $e->getMessage()); + } + + $this->assertSame($expected, $processor->toArray()); + } +} + +final class ArrayProcessorFixture +{ + public string $value; + + public function __construct(string $value) + { + $this->value = $value; + } +} + +final class ArrayProcessorStringableValue +{ + private string $value; + + public function __construct(string $value) + { + $this->value = $value; + } + + public function __toString(): string + { + return $this->value; + } +} diff --git a/tests/Unit/AutoloadTest.php b/tests/Unit/AutoloadTest.php new file mode 100644 index 0000000..5c204f7 --- /dev/null +++ b/tests/Unit/AutoloadTest.php @@ -0,0 +1,15 @@ +assertTrue(class_exists(ItemNotFound::class)); + $this->assertInstanceOf(\Exception::class, new ItemNotFound()); + } +} diff --git a/tests/Unit/ClosureAdapterTest.php b/tests/Unit/ClosureAdapterTest.php new file mode 100644 index 0000000..68376be --- /dev/null +++ b/tests/Unit/ClosureAdapterTest.php @@ -0,0 +1,38 @@ +expectException(ReflectionException::class); + $this->expectExceptionMessage('internal closure'); + + ClosureAdapter::getClosureData($closure); + } + + public function testUnreadableClosureSourceIsWrappedInTheDeclaredException(): void + { + $closure = eval('return static function (): void {};'); + if (!$closure instanceof Closure) { + $this->fail('Expected eval to return a closure.'); + } + + try { + ClosureAdapter::getClosureData($closure); + $this->fail('Expected unreadable closure source to be rejected.'); + } catch (ReflectionException $e) { + $this->assertInstanceOf(RuntimeException::class, $e->getPrevious()); + $this->assertStringContainsString('source file', $e->getMessage()); + } + } +} diff --git a/tests/Unit/ListFilterTest.php b/tests/Unit/ListFilterTest.php new file mode 100644 index 0000000..5098565 --- /dev/null +++ b/tests/Unit/ListFilterTest.php @@ -0,0 +1,233 @@ +expectException(InvalidArgumentException::class); + + ListFilter::seed(['id__callback' => 42], $this->items(1))->filter(); + } + + public function testMalformedAndFilterIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + + ListFilter::seed(['id__and' => 42], $this->items(1))->filter(); + } + + /** + * @dataProvider malformedKeyFilterCases + * @param array $filter + */ + public function testMalformedKeyFilterIsRejected(array $filter): void + { + $this->expectException(InvalidArgumentException::class); + + ListFilter::seed($filter, $this->items(1))->filter(); + } + + /** @return iterable}> */ + public static function malformedKeyFilterCases(): iterable + { + yield 'include is not an array' => [['filter_enum_key__in' => 42]]; + yield 'exclude is not an array' => [['filter_enum_key__not_in' => 42]]; + yield 'include has a non-key value' => [['filter_enum_key__in' => [new \stdClass()]]]; + yield 'exclude has a non-key value' => [['filter_enum_key__not_in' => [new \stdClass()]]]; + } + + public function testNormalBuilderFiltersRemainSupported(): void + { + $result = (new ListFilter($this->items(1, 2, 3))) + ->filterFromCallback('id', static fn (int $id): bool => $id >= 2) + ->and('id', 2) + ->filter(); + + $this->assertSame([2], $this->ids($result)); + } + + public function testUnknownSeedOperatorKeepsExistingPassThroughBehavior(): void + { + $result = ListFilter::seed( + ['id__future_operator' => 'unused'], + $this->items(1, 2) + )->filter(); + + $this->assertSame([1, 2], $this->ids($result)); + } + + public function testLimitAndOffsetReturnTheRequestedWindow(): void + { + $items = $this->items(1, 2, 3, 4, 5); + + $result = (new ListFilter($items))->limit(2)->offset(2)->filter(); + + $this->assertSame([3, 4], $this->ids($result)); + } + + public function testOffsetWithoutLimitReturnsEveryRemainingItem(): void + { + $items = $this->items(1, 2, 3, 4, 5); + + $result = (new ListFilter($items))->offset(2)->filter(); + + $this->assertSame([3, 4, 5], $this->ids($result)); + } + + public function testLimitWithoutOffsetStopsAtTheRequestedCount(): void + { + $items = $this->items(1, 2, 3, 4, 5); + + $result = (new ListFilter($items))->limit(2)->filter(); + + $this->assertSame([1, 2], $this->ids($result)); + } + + /** @dataProvider invalidLimitCases */ + public function testInvalidLimitIsRejectedWithoutChangingThePreviousLimit(int $invalidLimit): void + { + $filter = (new ListFilter($this->items(1, 2, 3)))->limit(2); + + try { + $filter->limit($invalidLimit); + $this->fail('Expected an invalid limit to be rejected.'); + } catch (InvalidArgumentException $e) { + $this->assertStringContainsString('positive', $e->getMessage()); + } + + $this->assertSame([1, 2], $this->ids($filter->filter())); + } + + /** @return iterable */ + public static function invalidLimitCases(): iterable + { + yield 'zero' => [0]; + yield 'negative' => [-1]; + } + + public function testNegativeOffsetIsRejectedWithoutChangingThePreviousOffset(): void + { + $filter = (new ListFilter($this->items(1, 2, 3)))->offset(1); + + try { + $filter->offset(-1); + $this->fail('Expected a negative offset to be rejected.'); + } catch (InvalidArgumentException $e) { + $this->assertStringContainsString('non-negative', $e->getMessage()); + } + + $this->assertSame([2, 3], $this->ids($filter->filter())); + } + + public function testComparableFilterValuesRemainSupported(): void + { + $stringable = new ListFilterStringableValue('wanted'); + $items = [ + 'first' => new ListFilterValueItem(false), + 'second' => new ListFilterValueItem(1.5), + 'third' => new ListFilterValueItem(2), + 'fourth' => new ListFilterValueItem('three'), + 'fifth' => new ListFilterValueItem(null), + 'sixth' => new ListFilterValueItem($stringable), + ]; + + $this->assertSame( + $items, + (new ListFilter($items))->in('value', false, 1.5, 2, 'three', null, $stringable)->filter() + ); + } + + /** + * @dataProvider invalidComparableFilterCases + * @param array $filter + */ + public function testComparableFiltersRejectInvalidValues(array $filter, object $item): void + { + $this->expectException(InvalidArgumentException::class); + + ListFilter::seed($filter, [$item])->filter(); + } + + /** @return iterable, object}> */ + public static function invalidComparableFilterCases(): iterable + { + yield 'invalid filter argument' => [ + ['value__in' => [['invalid']]], + new ListFilterValueItem('valid'), + ]; + yield 'invalid item value' => [ + ['value__in' => ['valid']], + new ListFilterValueItem([['invalid']]), + ]; + } + + /** + * @param int ...$ids + * @return array + */ + private function items(int ...$ids): array + { + return array_map( + static fn (int $id): ListFilterItem => new ListFilterItem($id), + $ids + ); + } + + /** + * @param array $items + * @return list + */ + private function ids(array $items): array + { + $ids = []; + foreach ($items as $item) { + $this->assertInstanceOf(ListFilterItem::class, $item); + $ids[] = $item->id; + } + + return $ids; + } +} + +final class ListFilterItem +{ + public int $id; + + public function __construct(int $id) + { + $this->id = $id; + } +} + +final class ListFilterValueItem +{ + /** @var mixed */ + public $value; + + /** @param mixed $value */ + public function __construct($value) + { + $this->value = $value; + } +} + +final class ListFilterStringableValue +{ + private string $value; + + public function __construct(string $value) + { + $this->value = $value; + } + + public function __toString(): string + { + return $this->value; + } +} diff --git a/tests/Unit/ObjTest.php b/tests/Unit/ObjTest.php new file mode 100644 index 0000000..f29d844 --- /dev/null +++ b/tests/Unit/ObjTest.php @@ -0,0 +1,111 @@ +assertNull(Obj::pluck($item, 'name')); + } + + public function testMagicGetterIsPreserved(): void + { + $item = new class { + /** @return mixed */ + public function __get(string $name) + { + return $name === 'virtual' ? 'value' : null; + } + }; + + $this->assertSame('value', Obj::pluck($item, 'virtual')); + } + + public function testMissingPropertyThrowsItemNotFound(): void + { + $this->expectException(ItemNotFound::class); + + Obj::pluck((object) [], 'missing'); + } + + public function testPrivatePropertyThrowsItemNotFound(): void + { + $item = new class { + private string $secret = 'hidden'; + + public function reveal(): string + { + return $this->secret; + } + }; + + $this->expectException(ItemNotFound::class); + + Obj::pluck($item, 'secret'); + } + + public function testMagicGetterCanExposeAnOtherwisePrivateProperty(): void + { + $item = new class { + private string $secret = 'hidden'; + + /** @return mixed */ + public function __get(string $name) + { + return $name === 'secret' ? $this->secret : null; + } + }; + + $this->assertSame('hidden', Obj::pluck($item, 'secret')); + } + + public function testScalarIntermediateThrowsItemNotFound(): void + { + $item = (object) ['nested' => 42]; + + $this->expectException(ItemNotFound::class); + + Obj::pluck($item, 'nested.value'); + } + + public function testGetterExceptionIsNotWrapped(): void + { + $item = new class { + public function getName(): string + { + throw new RuntimeException('getter failed'); + } + }; + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('getter failed'); + + Obj::pluck($item, 'name'); + } + + public function testMagicGetterExceptionIsNotWrapped(): void + { + $item = new class { + /** @return mixed */ + public function __get(string $name) + { + throw new RuntimeException("magic getter failed for $name"); + } + }; + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('magic getter failed'); + + Obj::pluck($item, 'name'); + } +} diff --git a/tests/Unit/StrTest.php b/tests/Unit/StrTest.php index 5697d5e..2e22006 100644 --- a/tests/Unit/StrTest.php +++ b/tests/Unit/StrTest.php @@ -7,6 +7,37 @@ class StrTest extends TestCase { + public function testClosureHashIncludesCapturedValues(): void + { + $first = $this->closureWithCapture(10); + $same = $this->closureWithCapture(10); + $different = $this->closureWithCapture(20); + + $this->assertSame(Str::createHash($first), Str::createHash($same)); + $this->assertNotSame(Str::createHash($first), Str::createHash($different)); + } + + public function testBasenameReturnsNullForNonStringArguments(): void + { + $this->assertNull(Str::basename(null)); + $this->assertNull(Str::basename(123)); + $this->assertNull(Str::basename('path/to/file.php', null)); + $this->assertNull(Str::basename('path/to/file.php', '')); + } + + public function testBasenamePreservesValidStringBehavior(): void + { + $this->assertSame('file.php', Str::basename('path/to/file.php')); + $this->assertSame('value', Str::basename('prefix::value', '::')); + } + + private function closureWithCapture(int $captured): \Closure + { + return static function () use ($captured): int { + return $captured; + }; + } + /** * Test the Str::before() method with various inputs *