From 2d012af05a085830715f405df6a6f2e1f7ca1139 Mon Sep 17 00:00:00 2001 From: Alex Standiford Date: Sun, 20 Sep 2026 15:40:49 -0400 Subject: [PATCH 01/14] Repair package quality gates --- .wordlist.txt | 9 ++++++++- composer.json | 3 ++- composer.lock | 2 +- lib/Processors/ListFilter.php | 1 + 4 files changed, 12 insertions(+), 3 deletions(-) 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..c797d9a 100644 --- a/composer.json +++ b/composer.json @@ -16,7 +16,8 @@ }, "autoload-dev": { "psr-4": { - "PHPNomad\\Utils\\Tests\\": "tests/" + "PHPNomad\\Core\\Tests\\": "tests/", + "PHPNomad\\Tests\\Unit\\": "tests/Unit/" } }, "authors": [ diff --git a/composer.lock b/composer.lock index 97b4cdb..c86daf9 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": "9a79c83077a29591f8ca56771ef7ac12", "packages": [], "packages-dev": [ { diff --git a/lib/Processors/ListFilter.php b/lib/Processors/ListFilter.php index 1f7b046..cbc31a5 100644 --- a/lib/Processors/ListFilter.php +++ b/lib/Processors/ListFilter.php @@ -6,6 +6,7 @@ use PHPNomad\Utils\Helpers\Arr; use PHPNomad\Utils\Helpers\Obj; +/** @phpstan-consistent-constructor */ class ListFilter { From a406a81a85c164af9cb18c122b00afee0cd73f17 Mon Sep 17 00:00:00 2001 From: Alex Standiford Date: Sun, 20 Sep 2026 16:05:05 -0400 Subject: [PATCH 02/14] Document utility value shapes --- lib/Helpers/Arr.php | 168 ++++++++++++++++-------------- lib/Helpers/ClosureAdapter.php | 2 +- lib/Helpers/Num.php | 5 +- lib/Helpers/Obj.php | 3 +- lib/Helpers/Str.php | 14 ++- lib/Processors/ArrayProcessor.php | 24 +++-- lib/Processors/ListFilter.php | 32 +++--- tests/Unit/ArrTest.php | 2 + 8 files changed, 143 insertions(+), 107 deletions(-) diff --git a/lib/Helpers/Arr.php b/lib/Helpers/Arr.php index c013f1e..9e4b67d 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,7 +39,7 @@ public static function map(array $subject, callable $callback): array /** * Applies the callback to the elements of the given array. * - * @param array $subject + * @param array $subject * @param callable $callback * @param mixed $initial * @return mixed @@ -49,8 +52,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 +63,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 +74,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,9 +96,9 @@ public static function each(array $subject, callable $callback): array /** * Gets a value, if set. Falls back to default otherwise. * - * @param array $subject + * @param array $subject * @param string $key - * @param null $default + * @param mixed $default * @return mixed */ public static function get(array $subject, string $key, $default = null) @@ -110,10 +113,10 @@ public static function get(array $subject, string $key, $default = null) /** * Retrieve items after the specified array position. * - * @param array $subject the array + * @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,10 +126,10 @@ public static function after(array $subject, int $position): array /** * Retrieve items before the specified array position. * - * @param array $subject the array + * @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 { @@ -140,10 +143,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 +156,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 +173,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 +192,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 +204,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,9 +216,9 @@ 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) @@ -235,7 +242,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,9 +262,9 @@ 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 + * @param array $subject + * @param mixed $value + * @param mixed ...$values * @return bool */ public static function hasValues(array $subject, $value, ...$values): bool @@ -268,9 +275,9 @@ public static function hasValues(array $subject, $value, ...$values): bool /** * 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 +291,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 +307,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 +339,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 +361,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,11 +414,11 @@ 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 { @@ -430,9 +438,9 @@ public static function toIndexed(array $subject, string $key = 'key', string $va /** * Strips out duplicate items in the provided array. * - * @param array $subject + * @param array $subject * - * @return array + * @return array */ public static function unique(array $subject): array { @@ -442,7 +450,7 @@ public static function unique(array $subject): array /** * Sorts an array by the keys. * - * @param array $subject + * @param array $subject * * @return void */ @@ -454,8 +462,8 @@ 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 @@ -480,7 +488,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 +507,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 +519,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 +532,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 { @@ -556,10 +564,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 +583,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 +601,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 +615,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,6 +628,10 @@ public static function append(&$array, ...$items): void } } + /** + * @param array $array + * @return array + */ public static function flip($array): array { return array_flip($array); @@ -628,12 +640,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 @@ -667,9 +679,9 @@ public static function normalize(array $array, $convertClosures = true, $recursi /** * Returns an array that contains the values contained in all arrays. * - * @param array ...$items + * @param array ...$items * - * @return array + * @return array */ public static function intersect(array ...$items): array { @@ -679,9 +691,9 @@ public static function intersect(array ...$items): array /** * 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,9 +703,9 @@ public static function intersectKeys(array ...$items): array /** * Returns an array that contains values only contained in a single array. * - * @param array ...$items + * @param array ...$items * - * @return array + * @return array */ public static function diff(array ...$items): array { @@ -703,9 +715,9 @@ public static function diff(array ...$items): array /** * 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 { @@ -715,9 +727,9 @@ public static function replaceRecursive(array ...$items): array /** * 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 { @@ -727,8 +739,8 @@ public static function replace(array ...$items): array /** * 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) @@ -739,8 +751,8 @@ public static function first(array $items, $default = null) /** * 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) diff --git a/lib/Helpers/ClosureAdapter.php b/lib/Helpers/ClosureAdapter.php index 2ecb15d..ee270e8 100644 --- a/lib/Helpers/ClosureAdapter.php +++ b/lib/Helpers/ClosureAdapter.php @@ -14,7 +14,7 @@ class ClosureAdapter * * @param Closure $data * - * @return array + * @return array{string, array} * @throws ReflectionException */ public static function getClosureData(Closure $data): array 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..4ddbca5 100644 --- a/lib/Helpers/Obj.php +++ b/lib/Helpers/Obj.php @@ -10,6 +10,7 @@ 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) @@ -48,4 +49,4 @@ public static function implements(string $instance, string $implements, string . 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..5f144fb 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 @@ -39,6 +39,7 @@ public static function camelCase(string $subject): string public static function camelCaseToDashCase(string $subject): string { $subject = preg_replace('/[A-Z]/', '-$0', $subject); + /** @var string $subject A fixed valid expression always returns a string. */ $subject = strtolower($subject); return ltrim($subject, '-'); @@ -92,6 +93,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; @@ -254,6 +259,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 +270,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 diff --git a/lib/Processors/ArrayProcessor.php b/lib/Processors/ArrayProcessor.php index 50980e4..0d78262 100644 --- a/lib/Processors/ArrayProcessor.php +++ b/lib/Processors/ArrayProcessor.php @@ -8,14 +8,20 @@ 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 +30,7 @@ public function pluck(string $key, $default = null) } /** - * @return array + * @return array */ public function toArray(): array { @@ -165,7 +171,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 +185,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 +199,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 +242,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 */ @@ -363,7 +369,7 @@ 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 */ @@ -377,7 +383,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,7 +397,7 @@ 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 */ diff --git a/lib/Processors/ListFilter.php b/lib/Processors/ListFilter.php index cbc31a5..6a1877f 100644 --- a/lib/Processors/ListFilter.php +++ b/lib/Processors/ListFilter.php @@ -10,11 +10,15 @@ 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; @@ -83,7 +87,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 */ @@ -97,7 +101,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 */ @@ -111,7 +115,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 */ @@ -140,7 +144,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 mixed ...$values The values to filter. * * @return $this */ @@ -155,7 +159,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 mixed ...$values The values to filter. * * @return $this */ @@ -170,7 +174,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 mixed ...$values The values to filter. * * @return $this */ @@ -200,7 +204,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 */ @@ -214,7 +218,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 */ @@ -226,9 +230,9 @@ public function keyIn(...$values) } /** - * @param $key + * @param string $key * - * @return array + * @return array{field: string, type: string} */ protected function prepareField($key): array { @@ -322,7 +326,7 @@ protected function filterItem(object $item): ?object /** * Pre-filters the list of items. * - * @return array + * @return array */ protected function filterItemKeys(): array { @@ -404,13 +408,17 @@ public function offset(?int $offset = null) } /** - * @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/ArrTest.php b/tests/Unit/ArrTest.php index c65bdd2..f94b21a 100644 --- a/tests/Unit/ArrTest.php +++ b/tests/Unit/ArrTest.php @@ -9,12 +9,14 @@ class ArrTest extends TestCase { /** * @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' => [ From 826c65e7a9fd6595990c85c73cdd4a0b386a76b5 Mon Sep 17 00:00:00 2001 From: Alex Standiford Date: Sun, 20 Sep 2026 16:11:35 -0400 Subject: [PATCH 03/14] Repair array helper semantics --- lib/Helpers/Arr.php | 45 +++++++++---------- lib/Processors/ArrayProcessor.php | 6 +-- lib/Processors/ListFilter.php | 13 +++--- tests/Unit/ArrTest.php | 34 ++++++++++++++ tests/Unit/ArrayProcessorTest.php | 30 +++++++++++++ tests/Unit/ListFilterTest.php | 73 +++++++++++++++++++++++++++++++ 6 files changed, 167 insertions(+), 34 deletions(-) create mode 100644 tests/Unit/ArrayProcessorTest.php create mode 100644 tests/Unit/ListFilterTest.php diff --git a/lib/Helpers/Arr.php b/lib/Helpers/Arr.php index 9e4b67d..b02836e 100644 --- a/lib/Helpers/Arr.php +++ b/lib/Helpers/Arr.php @@ -39,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) { @@ -113,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 { @@ -224,11 +227,11 @@ public static function keys(array $subject): array 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; @@ -425,7 +428,7 @@ public static function toIndexed(array $subject, string $key = 'key', string $va $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]); @@ -468,14 +471,10 @@ public static function keySort(array &$subject): void */ 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; @@ -654,9 +653,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. @@ -721,7 +718,7 @@ public static function diff(array ...$items): array */ public static function replaceRecursive(array ...$items): array { - return array_replace_recursive($items); + return array_replace_recursive(...$items); } /** @@ -733,7 +730,7 @@ public static function replaceRecursive(array ...$items): array */ public static function replace(array ...$items): array { - return array_replace($items); + return array_replace(...$items); } /** @@ -745,7 +742,7 @@ public static function replace(array ...$items): array */ public static function first(array $items, $default = null) { - return Arr::get(array_values($items), 0, $default); + return Arr::get(array_values($items), '0', $default); } /** @@ -759,6 +756,6 @@ 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/Processors/ArrayProcessor.php b/lib/Processors/ArrayProcessor.php index 0d78262..affa5aa 100644 --- a/lib/Processors/ArrayProcessor.php +++ b/lib/Processors/ArrayProcessor.php @@ -104,7 +104,7 @@ 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 */ @@ -296,8 +296,8 @@ public function map(callable $callback): ArrayProcessor } /** - * @param callable $callback - * @param mixed $initial + * @param callable(array, mixed): array $callback + * @param array $initial * * @return $this */ diff --git a/lib/Processors/ListFilter.php b/lib/Processors/ListFilter.php index 6a1877f..64bc1a7 100644 --- a/lib/Processors/ListFilter.php +++ b/lib/Processors/ListFilter.php @@ -354,10 +354,9 @@ protected function filterItemKeys(): array 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) { @@ -371,13 +370,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; diff --git a/tests/Unit/ArrTest.php b/tests/Unit/ArrTest.php index f94b21a..f344cfe 100644 --- a/tests/Unit/ArrTest.php +++ b/tests/Unit/ArrTest.php @@ -7,6 +7,40 @@ class ArrTest extends TestCase { + 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']] + ) + ); + } + /** * @dataProvider hasDottedPathCases * @param array $subject diff --git a/tests/Unit/ArrayProcessorTest.php b/tests/Unit/ArrayProcessorTest.php new file mode 100644 index 0000000..8678f23 --- /dev/null +++ b/tests/Unit/ArrayProcessorTest.php @@ -0,0 +1,30 @@ + 10], + ['id' => 20], + ]); + + $returned = $processor->reduce( + static function (array $ids, $item): array { + /** @var array{id: int} $item */ + $ids[] = $item['id']; + + return $ids; + }, + [] + ); + + $this->assertSame($processor, $returned); + $this->assertSame([10, 20], $processor->toArray()); + } +} diff --git a/tests/Unit/ListFilterTest.php b/tests/Unit/ListFilterTest.php new file mode 100644 index 0000000..18224dd --- /dev/null +++ b/tests/Unit/ListFilterTest.php @@ -0,0 +1,73 @@ +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)); + } + + /** + * @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; + } +} From 175190969da00615700cb4a3e64834d0972a97f6 Mon Sep 17 00:00:00 2001 From: Alex Standiford Date: Sun, 20 Sep 2026 16:11:35 -0400 Subject: [PATCH 04/14] Clarify helper control flow --- lib/Helpers/Obj.php | 10 ++++++---- lib/Helpers/Str.php | 4 ++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/lib/Helpers/Obj.php b/lib/Helpers/Obj.php index 4ddbca5..09462d5 100644 --- a/lib/Helpers/Obj.php +++ b/lib/Helpers/Obj.php @@ -18,11 +18,13 @@ public static function pluck(object $value, string $fields) $fields = explode('.', $fields); foreach ($fields as $field) { $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"]); + if (is_callable([$value, $camelGetter])) { + $value = $value->{$camelGetter}(); + } elseif (is_callable([$value, $snakeGetter])) { + $value = $value->{$snakeGetter}(); } else { try { $value = $value->$field; diff --git a/lib/Helpers/Str.php b/lib/Helpers/Str.php index 5f144fb..e220e61 100644 --- a/lib/Helpers/Str.php +++ b/lib/Helpers/Str.php @@ -38,8 +38,7 @@ public static function camelCase(string $subject): string public static function camelCaseToDashCase(string $subject): string { - $subject = preg_replace('/[A-Z]/', '-$0', $subject); - /** @var string $subject A fixed valid expression always returns a string. */ + $subject = preg_replace('/[A-Z]/', '-$0', $subject) ?? $subject; $subject = strtolower($subject); return ltrim($subject, '-'); @@ -283,6 +282,7 @@ public static function enumerate(array $enumerableItems, string $conjunction = ' $enumerableItems = Arr::cast($enumerableItems, 'string'); $lastItem = array_pop($enumerableItems); + /** @var string $lastItem */ $result = $enumerableItems ? implode(', ', $enumerableItems) . ", $conjunction " . $lastItem : $lastItem; From af49c84d9f407b02af43bffe3702b2831fa24934 Mon Sep 17 00:00:00 2001 From: Alex Standiford Date: Sun, 20 Sep 2026 16:19:49 -0400 Subject: [PATCH 05/14] Make utility failure contracts explicit --- composer.json | 5 +- lib/Helpers/Arr.php | 6 +- lib/Helpers/ClosureAdapter.php | 9 ++- lib/Helpers/Obj.php | 29 ++++++++-- lib/Helpers/Str.php | 21 +++++-- lib/Processors/ArrayProcessor.php | 41 +++++++++++-- lib/Processors/ListFilter.php | 40 ++++++++++++- tests/Unit/ArrTest.php | 8 +++ tests/Unit/ArrayProcessorTest.php | 93 ++++++++++++++++++++++++++++++ tests/Unit/AutoloadTest.php | 15 +++++ tests/Unit/ClosureAdapterTest.php | 21 +++++++ tests/Unit/ListFilterTest.php | 55 ++++++++++++++++++ tests/Unit/ObjTest.php | 96 +++++++++++++++++++++++++++++++ tests/Unit/StrTest.php | 30 ++++++++++ 14 files changed, 447 insertions(+), 22 deletions(-) create mode 100644 tests/Unit/AutoloadTest.php create mode 100644 tests/Unit/ClosureAdapterTest.php create mode 100644 tests/Unit/ObjTest.php diff --git a/composer.json b/composer.json index c797d9a..df2c6b9 100644 --- a/composer.json +++ b/composer.json @@ -12,7 +12,10 @@ "autoload": { "psr-4": { "PHPNomad\\Utils\\": "lib/" - } + }, + "classmap": [ + "lib/Exceptions/" + ] }, "autoload-dev": { "psr-4": { diff --git a/lib/Helpers/Arr.php b/lib/Helpers/Arr.php index b02836e..62f7537 100644 --- a/lib/Helpers/Arr.php +++ b/lib/Helpers/Arr.php @@ -531,7 +531,7 @@ public static function reverse(array $subject, bool $preserveKeys = true): array /** * Recursively plucks values from a set of items. * - * @param 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. * @@ -548,7 +548,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)); @@ -631,7 +631,7 @@ public static function append(&$array, ...$items): void * @param array $array * @return array */ - public static function flip($array): array + public static function flip(array $array): array { return array_flip($array); } diff --git a/lib/Helpers/ClosureAdapter.php b/lib/Helpers/ClosureAdapter.php index ee270e8..c4e04b5 100644 --- a/lib/Helpers/ClosureAdapter.php +++ b/lib/Helpers/ClosureAdapter.php @@ -16,11 +16,16 @@ class ClosureAdapter * * @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.'); + } + + $file = new SplFileObject($filename); $file->seek($ref->getStartLine() - 1); $content = ''; while ($file->key() < $ref->getEndLine()) { diff --git a/lib/Helpers/Obj.php b/lib/Helpers/Obj.php index 09462d5..d10df49 100644 --- a/lib/Helpers/Obj.php +++ b/lib/Helpers/Obj.php @@ -2,8 +2,8 @@ namespace PHPNomad\Utils\Helpers; -use Exception; use PHPNomad\Core\Exceptions\ItemNotFound; +use Throwable; class Obj { @@ -17,6 +17,10 @@ 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"; @@ -25,18 +29,33 @@ public static function pluck(object $value, string $fields) $value = $value->{$camelGetter}(); } elseif (is_callable([$value, $snakeGetter])) { $value = $value->{$snakeGetter}(); - } else { + } 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 (property_exists($value, $field)) { + throw new ItemNotFound("Cannot read inaccessible or uninitialized field '$field'."); + } elseif (method_exists($value, '__get')) { + $value = $value->$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 diff --git a/lib/Helpers/Str.php b/lib/Helpers/Str.php index e220e61..e1b2ff4 100644 --- a/lib/Helpers/Str.php +++ b/lib/Helpers/Str.php @@ -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 { @@ -203,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 === '') { + throw new \ValueError('explode(): Argument #1 ($separator) cannot be empty'); + } + $items = explode($divider, $subject); return array_pop($items); diff --git a/lib/Processors/ArrayProcessor.php b/lib/Processors/ArrayProcessor.php index affa5aa..0ca4aa7 100644 --- a/lib/Processors/ArrayProcessor.php +++ b/lib/Processors/ArrayProcessor.php @@ -2,6 +2,7 @@ namespace PHPNomad\Utils\Processors; +use InvalidArgumentException; use PHPNomad\Utils\Helpers\Arr; use ReflectionException; @@ -54,7 +55,15 @@ public function count(): int */ public function flip(): ArrayProcessor { - $this->subject = Arr::flip($this->subject); + foreach ($this->subject as $item) { + if (!is_int($item) && !is_string($item)) { + throw new InvalidArgumentException('Flipping requires every subject item to be an integer or string.'); + } + } + + /** @var array $subject */ + $subject = $this->subject; + $this->subject = Arr::flip($subject); return $this; } @@ -110,7 +119,15 @@ public function remove($key): ArrayProcessor */ public function hydrate(string $instance): ArrayProcessor { - $this->subject = Arr::hydrate($this->subject, $instance); + foreach ($this->subject as $item) { + if (!is_array($item)) { + throw new InvalidArgumentException('Hydration requires every subject item to be an array.'); + } + } + + /** @var array> $subject */ + $subject = $this->subject; + $this->subject = Arr::hydrate($subject, $instance); return $this; } @@ -137,14 +154,30 @@ public function hydrate(string $instance): ArrayProcessor */ public function flatten(string $groupKey = 'group'): ArrayProcessor { - $this->subject = Arr::flatten($this->subject, $groupKey); + foreach ($this->subject as $item) { + if (!is_array($item)) { + throw new InvalidArgumentException('Flattening requires every subject item to be an array.'); + } + } + + /** @var array> $subject */ + $subject = $this->subject; + $this->subject = Arr::flatten($subject, $groupKey); return $this; } public function group(string ...$groups): ArrayProcessor { - $this->subject = Arr::group($this->subject, ...$groups); + foreach ($this->subject as $item) { + if (!is_array($item) && !is_object($item)) { + throw new InvalidArgumentException('Grouping requires every subject item to be an array or object.'); + } + } + + /** @var array|object> $subject */ + $subject = $this->subject; + $this->subject = Arr::group($subject, ...$groups); return $this; } diff --git a/lib/Processors/ListFilter.php b/lib/Processors/ListFilter.php index 64bc1a7..af7f09b 100644 --- a/lib/Processors/ListFilter.php +++ b/lib/Processors/ListFilter.php @@ -2,6 +2,7 @@ namespace PHPNomad\Utils\Processors; +use InvalidArgumentException; use PHPNomad\Core\Exceptions\ItemNotFound; use PHPNomad\Utils\Helpers\Arr; use PHPNomad\Utils\Helpers\Obj; @@ -277,6 +278,9 @@ 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 { @@ -290,6 +294,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': @@ -334,18 +341,47 @@ 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 = Arr::intersect($items, $includedKeys); + /** @var array $items */ 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 = Arr::diff($items, $excludedKeys); + /** @var array $items */ 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; + } + /** * Queries a loader registry. * diff --git a/tests/Unit/ArrTest.php b/tests/Unit/ArrTest.php index f344cfe..7a4acaa 100644 --- a/tests/Unit/ArrTest.php +++ b/tests/Unit/ArrTest.php @@ -41,6 +41,14 @@ public function testReplaceRecursiveCombinesNestedArrays(): void ); } + public function testPluckUsesTheDefaultForScalarItems(): void + { + $this->assertSame( + ['missing', 'found'], + Arr::pluck([42, ['name' => 'found']], 'name', 'missing') + ); + } + /** * @dataProvider hasDottedPathCases * @param array $subject diff --git a/tests/Unit/ArrayProcessorTest.php b/tests/Unit/ArrayProcessorTest.php index 8678f23..bafe625 100644 --- a/tests/Unit/ArrayProcessorTest.php +++ b/tests/Unit/ArrayProcessorTest.php @@ -2,11 +2,75 @@ namespace PHPNomad\Tests\Unit; +use InvalidArgumentException; use PHPNomad\Core\Tests\TestCase; use PHPNomad\Utils\Processors\ArrayProcessor; class ArrayProcessorTest extends TestCase { + public function testFlipPreservesValidBehavior(): void + { + $processor = new ArrayProcessor(['first' => '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([ @@ -27,4 +91,33 @@ static function (array $ids, $item): array { $this->assertSame($processor, $returned); $this->assertSame([10, 20], $processor->toArray()); } + + /** + * @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; + } } 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..0d03de5 --- /dev/null +++ b/tests/Unit/ClosureAdapterTest.php @@ -0,0 +1,21 @@ +expectException(ReflectionException::class); + $this->expectExceptionMessage('internal closure'); + + ClosureAdapter::getClosureData($closure); + } +} diff --git a/tests/Unit/ListFilterTest.php b/tests/Unit/ListFilterTest.php index 18224dd..8640797 100644 --- a/tests/Unit/ListFilterTest.php +++ b/tests/Unit/ListFilterTest.php @@ -2,11 +2,66 @@ namespace PHPNomad\Tests\Unit; +use InvalidArgumentException; use PHPNomad\Core\Tests\TestCase; use PHPNomad\Utils\Processors\ListFilter; class ListFilterTest extends TestCase { + public function testMalformedCallbackFilterIsRejected(): void + { + $this->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); diff --git a/tests/Unit/ObjTest.php b/tests/Unit/ObjTest.php new file mode 100644 index 0000000..d611714 --- /dev/null +++ b/tests/Unit/ObjTest.php @@ -0,0 +1,96 @@ +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 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..3377148 100644 --- a/tests/Unit/StrTest.php +++ b/tests/Unit/StrTest.php @@ -7,6 +7,36 @@ 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)); + } + + 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 * From f59f4851f0917ac6175ccb5265149f00e05962bb Mon Sep 17 00:00:00 2001 From: Alex Standiford Date: Sun, 20 Sep 2026 16:20:32 -0400 Subject: [PATCH 06/14] Refresh vulnerable test tooling --- composer.json | 7 +- composer.lock | 211 ++++++++++++++++++++++++++++++-------------------- 2 files changed, 134 insertions(+), 84 deletions(-) diff --git a/composer.json b/composer.json index df2c6b9..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", @@ -30,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 c86daf9..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": "9a79c83077a29591f8ca56771ef7ac12", + "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" } From 1709d90a4ee4fd9382d3fe708a6d4504298505f4 Mon Sep 17 00:00:00 2001 From: Alex Standiford Date: Sun, 20 Sep 2026 16:24:00 -0400 Subject: [PATCH 07/14] Preserve magic access and typed validation --- lib/Helpers/ClosureAdapter.php | 8 ++- lib/Helpers/Obj.php | 4 +- lib/Helpers/Str.php | 18 +++++- lib/Processors/ArrayProcessor.php | 96 +++++++++++++++++++------------ lib/Processors/ListFilter.php | 40 +++++++++++-- tests/Unit/ArrayProcessorTest.php | 4 +- tests/Unit/ClosureAdapterTest.php | 17 ++++++ tests/Unit/ObjTest.php | 15 +++++ 8 files changed, 155 insertions(+), 47 deletions(-) diff --git a/lib/Helpers/ClosureAdapter.php b/lib/Helpers/ClosureAdapter.php index c4e04b5..400ddcf 100644 --- a/lib/Helpers/ClosureAdapter.php +++ b/lib/Helpers/ClosureAdapter.php @@ -16,7 +16,7 @@ class ClosureAdapter * * @return array{string, array} * @throws ReflectionException - */ + */ public static function getClosureData(Closure $data): array { $ref = new ReflectionFunction($data); @@ -25,7 +25,11 @@ public static function getClosureData(Closure $data): array throw new ReflectionException('Cannot read source for an internal closure.'); } - $file = new SplFileObject($filename); + 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/Obj.php b/lib/Helpers/Obj.php index d10df49..a036377 100644 --- a/lib/Helpers/Obj.php +++ b/lib/Helpers/Obj.php @@ -35,10 +35,10 @@ public static function pluck(object $value, string $fields) } catch (Throwable $e) { throw new ItemNotFound("Cannot read field '$field'.", 0, $e); } - } elseif (property_exists($value, $field)) { - throw new ItemNotFound("Cannot read inaccessible or uninitialized field '$field'."); } 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."); } diff --git a/lib/Helpers/Str.php b/lib/Helpers/Str.php index e1b2ff4..7fa6289 100644 --- a/lib/Helpers/Str.php +++ b/lib/Helpers/Str.php @@ -291,9 +291,8 @@ public static function enumerate(array $enumerableItems, string $conjunction = ' return ''; } - $enumerableItems = Arr::cast($enumerableItems, 'string'); + $enumerableItems = self::stringifyItems($enumerableItems); $lastItem = array_pop($enumerableItems); - /** @var string $lastItem */ $result = $enumerableItems ? implode(', ', $enumerableItems) . ", $conjunction " . $lastItem : $lastItem; @@ -303,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 0ca4aa7..b5c2939 100644 --- a/lib/Processors/ArrayProcessor.php +++ b/lib/Processors/ArrayProcessor.php @@ -55,15 +55,7 @@ public function count(): int */ public function flip(): ArrayProcessor { - foreach ($this->subject as $item) { - if (!is_int($item) && !is_string($item)) { - throw new InvalidArgumentException('Flipping requires every subject item to be an integer or string.'); - } - } - - /** @var array $subject */ - $subject = $this->subject; - $this->subject = Arr::flip($subject); + $this->subject = Arr::flip($this->getFlippableSubject()); return $this; } @@ -119,15 +111,7 @@ public function remove($key): ArrayProcessor */ public function hydrate(string $instance): ArrayProcessor { - foreach ($this->subject as $item) { - if (!is_array($item)) { - throw new InvalidArgumentException('Hydration requires every subject item to be an array.'); - } - } - - /** @var array> $subject */ - $subject = $this->subject; - $this->subject = Arr::hydrate($subject, $instance); + $this->subject = Arr::hydrate($this->getHydrationSubject(), $instance); return $this; } @@ -154,30 +138,14 @@ public function hydrate(string $instance): ArrayProcessor */ public function flatten(string $groupKey = 'group'): ArrayProcessor { - foreach ($this->subject as $item) { - if (!is_array($item)) { - throw new InvalidArgumentException('Flattening requires every subject item to be an array.'); - } - } - - /** @var array> $subject */ - $subject = $this->subject; - $this->subject = Arr::flatten($subject, $groupKey); + $this->subject = Arr::flatten($this->getFlattenableSubject(), $groupKey); return $this; } public function group(string ...$groups): ArrayProcessor { - foreach ($this->subject as $item) { - if (!is_array($item) && !is_object($item)) { - throw new InvalidArgumentException('Grouping requires every subject item to be an array or object.'); - } - } - - /** @var array|object> $subject */ - $subject = $this->subject; - $this->subject = Arr::group($subject, ...$groups); + $this->subject = Arr::group($this->getGroupableSubject(), ...$groups); return $this; } @@ -469,4 +437,60 @@ 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; + } } diff --git a/lib/Processors/ListFilter.php b/lib/Processors/ListFilter.php index af7f09b..57d0fd9 100644 --- a/lib/Processors/ListFilter.php +++ b/lib/Processors/ListFilter.php @@ -345,8 +345,7 @@ protected function filterItemKeys(): array if (!$this->isArrayOfKeys($includedKeys)) { throw new InvalidArgumentException('Key inclusion filters must contain an array of integer or string keys.'); } - $items = Arr::intersect($items, $includedKeys); - /** @var array $items */ + $items = $this->keepMatchingKeys($items, $includedKeys); unset($this->filter_args['filter_enum_key__in']); } @@ -355,8 +354,7 @@ protected function filterItemKeys(): array if (!$this->isArrayOfKeys($excludedKeys)) { throw new InvalidArgumentException('Key exclusion filters must contain an array of integer or string keys.'); } - $items = Arr::diff($items, $excludedKeys); - /** @var array $items */ + $items = $this->removeMatchingKeys($items, $excludedKeys); unset($this->filter_args['filter_enum_key__not_in']); } @@ -382,6 +380,40 @@ private function isArrayOfKeys($value): bool 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) { + if (is_int($item) || is_string($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) { + if (is_int($item) || is_string($item)) { + $result[$key] = $item; + } + } + + return $result; + } + /** * Queries a loader registry. * diff --git a/tests/Unit/ArrayProcessorTest.php b/tests/Unit/ArrayProcessorTest.php index bafe625..81bdecd 100644 --- a/tests/Unit/ArrayProcessorTest.php +++ b/tests/Unit/ArrayProcessorTest.php @@ -80,7 +80,9 @@ public function testReduceKeepsAnArrayAccumulatorInTheFluentProcessor(): void $returned = $processor->reduce( static function (array $ids, $item): array { - /** @var array{id: int} $item */ + if (!is_array($item) || !is_int($item['id'] ?? null)) { + throw new \UnexpectedValueException('Expected an integer ID.'); + } $ids[] = $item['id']; return $ids; diff --git a/tests/Unit/ClosureAdapterTest.php b/tests/Unit/ClosureAdapterTest.php index 0d03de5..68376be 100644 --- a/tests/Unit/ClosureAdapterTest.php +++ b/tests/Unit/ClosureAdapterTest.php @@ -6,6 +6,7 @@ use PHPNomad\Core\Tests\TestCase; use PHPNomad\Utils\Helpers\ClosureAdapter; use ReflectionException; +use RuntimeException; class ClosureAdapterTest extends TestCase { @@ -18,4 +19,20 @@ public function testInternalClosureIsRejectedWithTheDeclaredException(): void 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/ObjTest.php b/tests/Unit/ObjTest.php index d611714..f29d844 100644 --- a/tests/Unit/ObjTest.php +++ b/tests/Unit/ObjTest.php @@ -54,6 +54,21 @@ public function reveal(): string 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]; From fbcf1903e9228e571a2ffab9f7e184cf5877e43e Mon Sep 17 00:00:00 2001 From: Alex Standiford Date: Sun, 20 Sep 2026 16:32:11 -0400 Subject: [PATCH 08/14] Repair array prefix retrieval --- lib/Helpers/Arr.php | 7 ++++--- tests/Unit/ArrBeforeTest.php | 20 ++++++++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) create mode 100644 tests/Unit/ArrBeforeTest.php diff --git a/lib/Helpers/Arr.php b/lib/Helpers/Arr.php index 62f7537..3948f7c 100644 --- a/lib/Helpers/Arr.php +++ b/lib/Helpers/Arr.php @@ -129,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); } /** 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) + ); + } +} From a177ec19163f80952c7dba416d46e8d558cbcebc Mon Sep 17 00:00:00 2001 From: Alex Standiford Date: Sun, 20 Sep 2026 16:32:42 -0400 Subject: [PATCH 09/14] Complete utility runtime contracts --- lib/Helpers/Arr.php | 43 ++++++++---- lib/Helpers/Obj.php | 5 +- lib/Processors/ArrayProcessor.php | 58 +++++++++++++++-- lib/Processors/ListFilter.php | 56 ++++++++++++---- tests/Unit/ArrTest.php | 56 ++++++++++++++++ tests/Unit/ArrayProcessorTest.php | 82 +++++++++++++++++++++++ tests/Unit/ListFilterTest.php | 105 ++++++++++++++++++++++++++++++ 7 files changed, 371 insertions(+), 34 deletions(-) diff --git a/lib/Helpers/Arr.php b/lib/Helpers/Arr.php index 3948f7c..20da697 100644 --- a/lib/Helpers/Arr.php +++ b/lib/Helpers/Arr.php @@ -266,14 +266,15 @@ 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 mixed $value - * @param mixed ...$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))); } /** @@ -442,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 { @@ -677,13 +680,15 @@ 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)); } /** @@ -701,13 +706,15 @@ 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)); } /** @@ -719,7 +726,11 @@ public static function diff(array ...$items): array */ public static function replaceRecursive(array ...$items): array { - return array_replace_recursive(...$items); + if ($items === []) { + return []; + } + + return array_replace_recursive(...array_values($items)); } /** @@ -731,7 +742,11 @@ public static function replaceRecursive(array ...$items): array */ public static function replace(array ...$items): array { - return array_replace(...$items); + if ($items === []) { + return []; + } + + return array_replace(...array_values($items)); } /** diff --git a/lib/Helpers/Obj.php b/lib/Helpers/Obj.php index a036377..b90e93c 100644 --- a/lib/Helpers/Obj.php +++ b/lib/Helpers/Obj.php @@ -65,7 +65,10 @@ private static function readPublicProperty(object $value, string $field) 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); diff --git a/lib/Processors/ArrayProcessor.php b/lib/Processors/ArrayProcessor.php index b5c2939..286384c 100644 --- a/lib/Processors/ArrayProcessor.php +++ b/lib/Processors/ArrayProcessor.php @@ -164,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; } @@ -370,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; } @@ -398,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; } @@ -493,4 +499,46 @@ private function getGroupableSubject(): array 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 && !($value instanceof \Stringable)) { + throw new InvalidArgumentException( + 'Array comparison requires every value to be scalar, null, or stringable.' + ); + } + $comparable[$key] = $value; + } + + return $comparable; + } + + /** + * @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 57d0fd9..0f8dd62 100644 --- a/lib/Processors/ListFilter.php +++ b/lib/Processors/ListFilter.php @@ -145,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 mixed ...$values The values to filter. + * @param scalar|\Stringable|null ...$values The values to filter. * * @return $this */ @@ -160,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 mixed ...$values The values to filter. + * @param scalar|\Stringable|null ...$values The values to filter. * * @return $this */ @@ -175,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 mixed ...$values The values to filter. + * @param scalar|\Stringable|null ...$values The values to filter. * * @return $this */ @@ -191,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 */ @@ -284,7 +284,10 @@ protected function filterItem(object $item): ?object $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': @@ -389,9 +392,7 @@ private function keepMatchingKeys(array $items, array $includedKeys): array { $result = []; foreach (Arr::intersect($items, $includedKeys) as $key => $item) { - if (is_int($item) || is_string($item)) { - $result[$key] = $item; - } + $result[$key] = $item; } return $result; @@ -406,18 +407,35 @@ private function removeMatchingKeys(array $items, array $excludedKeys): array { $result = []; foreach (Arr::diff($items, $excludedKeys) as $key => $item) { - if (is_int($item) || is_string($item)) { - $result[$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 && !($value instanceof \Stringable)) { + throw new InvalidArgumentException( + "Filter '$filter' must contain only scalar, null, or stringable values." + ); + } + $comparable[$key] = $value; + } + + return $comparable; + } + /** * Queries a loader registry. * - * @return object[] Array of registry items. + * @return array Array of registry items. */ public function filter(): array { @@ -453,22 +471,32 @@ 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; diff --git a/tests/Unit/ArrTest.php b/tests/Unit/ArrTest.php index 7a4acaa..9d59d7f 100644 --- a/tests/Unit/ArrTest.php +++ b/tests/Unit/ArrTest.php @@ -41,6 +41,47 @@ public function testReplaceRecursiveCombinesNestedArrays(): void ); } + 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']]; + + $this->assertSame( + ['settings' => ['color' => 'red'], 'enabled' => true], + Arr::replace(first: $first, second: $second) + ); + $this->assertSame( + ['settings' => ['color' => 'red', 'size' => 'm'], 'enabled' => true], + Arr::replaceRecursive(first: $first, second: $second) + ); + } + + 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( @@ -125,3 +166,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 index 81bdecd..343666b 100644 --- a/tests/Unit/ArrayProcessorTest.php +++ b/tests/Unit/ArrayProcessorTest.php @@ -94,6 +94,73 @@ static function (array $ids, $item): array { $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 @@ -123,3 +190,18 @@ 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/ListFilterTest.php b/tests/Unit/ListFilterTest.php index 8640797..5098565 100644 --- a/tests/Unit/ListFilterTest.php +++ b/tests/Unit/ListFilterTest.php @@ -89,6 +89,84 @@ public function testLimitWithoutOffsetStopsAtTheRequestedCount(): void $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 @@ -126,3 +204,30 @@ 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; + } +} From d4f0d6693ad012c8c5f50b95181a096edf7107ed Mon Sep 17 00:00:00 2001 From: Alex Standiford Date: Sun, 20 Sep 2026 16:34:11 -0400 Subject: [PATCH 10/14] Preserve PHP 7 stringable values --- lib/Processors/ArrayProcessor.php | 11 ++++++++++- lib/Processors/ListFilter.php | 11 ++++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/lib/Processors/ArrayProcessor.php b/lib/Processors/ArrayProcessor.php index 286384c..8733103 100644 --- a/lib/Processors/ArrayProcessor.php +++ b/lib/Processors/ArrayProcessor.php @@ -514,7 +514,7 @@ private function getComparableValues(array $values): array { $comparable = []; foreach ($values as $key => $value) { - if (!is_scalar($value) && $value !== null && !($value instanceof \Stringable)) { + if (!is_scalar($value) && $value !== null && !$this->isStringableValue($value)) { throw new InvalidArgumentException( 'Array comparison requires every value to be scalar, null, or stringable.' ); @@ -525,6 +525,15 @@ private function getComparableValues(array $values): array 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> diff --git a/lib/Processors/ListFilter.php b/lib/Processors/ListFilter.php index 0f8dd62..b25beea 100644 --- a/lib/Processors/ListFilter.php +++ b/lib/Processors/ListFilter.php @@ -421,7 +421,7 @@ private function getComparableValues(array $values, string $filter): array { $comparable = []; foreach ($values as $key => $value) { - if (!is_scalar($value) && $value !== null && !($value instanceof \Stringable)) { + if (!is_scalar($value) && $value !== null && !$this->isStringableValue($value)) { throw new InvalidArgumentException( "Filter '$filter' must contain only scalar, null, or stringable values." ); @@ -432,6 +432,15 @@ private function getComparableValues(array $values, string $filter): array 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. * From de44560766548c7a8d75adedd44de035d98c3b55 Mon Sep 17 00:00:00 2001 From: Alex Standiford Date: Sun, 20 Sep 2026 16:40:29 -0400 Subject: [PATCH 11/14] Keep named-call proof PHP 7 compatible --- tests/Unit/ArrTest.php | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/tests/Unit/ArrTest.php b/tests/Unit/ArrTest.php index 9d59d7f..9799f75 100644 --- a/tests/Unit/ArrTest.php +++ b/tests/Unit/ArrTest.php @@ -52,13 +52,25 @@ 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], - Arr::replace(first: $first, second: $second) + $replace ); $this->assertSame( ['settings' => ['color' => 'red', 'size' => 'm'], 'enabled' => true], - Arr::replaceRecursive(first: $first, second: $second) + $replaceRecursive ); } From 5fc30be1acce8443c3aa2b346bf280bc963b1d0d Mon Sep 17 00:00:00 2001 From: Alex Standiford Date: Sun, 20 Sep 2026 16:43:01 -0400 Subject: [PATCH 12/14] Support integer array lookup keys --- lib/Helpers/Arr.php | 4 ++-- tests/Unit/ArrTest.php | 11 +++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/lib/Helpers/Arr.php b/lib/Helpers/Arr.php index 20da697..cad2f36 100644 --- a/lib/Helpers/Arr.php +++ b/lib/Helpers/Arr.php @@ -99,11 +99,11 @@ 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 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]; diff --git a/tests/Unit/ArrTest.php b/tests/Unit/ArrTest.php index 9799f75..c30cd18 100644 --- a/tests/Unit/ArrTest.php +++ b/tests/Unit/ArrTest.php @@ -7,6 +7,17 @@ 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( From 8f2a851f501231dcee653bd7fa699dc43f8db290 Mon Sep 17 00:00:00 2001 From: Alex Standiford Date: Sun, 20 Sep 2026 16:44:00 -0400 Subject: [PATCH 13/14] Stabilize empty basename dividers --- lib/Helpers/Str.php | 2 +- tests/Unit/StrTest.php | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/Helpers/Str.php b/lib/Helpers/Str.php index 7fa6289..ad27fb8 100644 --- a/lib/Helpers/Str.php +++ b/lib/Helpers/Str.php @@ -213,7 +213,7 @@ public static function basename($subject, $divider = '/'): ?string return null; } if ($divider === '') { - throw new \ValueError('explode(): Argument #1 ($separator) cannot be empty'); + return null; } $items = explode($divider, $subject); diff --git a/tests/Unit/StrTest.php b/tests/Unit/StrTest.php index 3377148..2e22006 100644 --- a/tests/Unit/StrTest.php +++ b/tests/Unit/StrTest.php @@ -22,6 +22,7 @@ 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 From 43aaa5600e72be963aa5e7c412fdae279ea9d762 Mon Sep 17 00:00:00 2001 From: Alex Standiford Date: Sun, 20 Sep 2026 16:47:35 -0400 Subject: [PATCH 14/14] Run the locked PHPUnit suite in CI --- .github/workflows/phpunit.yml | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) 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