diff --git a/system/Helpers/Array/ArrayHelper.php b/system/Helpers/Array/ArrayHelper.php index 6741dc4bbb0e..1c33fbaef0d1 100644 --- a/system/Helpers/Array/ArrayHelper.php +++ b/system/Helpers/Array/ArrayHelper.php @@ -16,12 +16,13 @@ use CodeIgniter\Exceptions\InvalidArgumentException; /** - * @interal This is internal implementation for the framework. + * @internal This is internal implementation for the framework. * * If there are any methods that should be provided, make them * public APIs via helper functions. * - * @see \CodeIgniter\Helpers\Array\ArrayHelperDotKeyExistsTest + * @see \CodeIgniter\Helpers\Array\ArrayHelperDotHasTest + * @see \CodeIgniter\Helpers\Array\ArrayHelperDotModifyTest * @see \CodeIgniter\Helpers\Array\ArrayHelperRecursiveDiffTest * @see \CodeIgniter\Helpers\Array\ArrayHelperSortValuesByNaturalTest */ @@ -49,13 +50,22 @@ public static function dotSearch(string $index, array $array) */ private static function convertToArray(string $index): array { + $trimmed = rtrim($index, '* '); + + if ($trimmed === '') { + return []; + } + + // Fast path: no escaped dots, skip the regex entirely. + if (! str_contains($trimmed, '\\.')) { + return array_values(array_filter( + explode('.', $trimmed), + static fn ($s): bool => $s !== '', + )); + } + // See https://regex101.com/r/44Ipql/1 - $segments = preg_split( - '/(? str_replace('\.', '.', $key), @@ -125,53 +135,174 @@ private static function arraySearchDot(array $indexes, array $array) * array_key_exists() with dot array syntax. * * If wildcard `*` is used, all items for the key after it must have the key. + * + * @param array $array */ - public static function dotKeyExists(string $index, array $array): bool + public static function dotHas(string $index, array $array): bool { - if (str_ends_with($index, '*') || str_contains($index, '*.*')) { - throw new InvalidArgumentException( - 'You must set key right after "*". Invalid index: "' . $index . '"', - ); - } + self::ensureValidWildcardPattern($index); $indexes = self::convertToArray($index); - // If indexes is empty, returns false. if ($indexes === []) { return false; } - $currentArray = $array; + return self::hasByDotPath($array, $indexes); + } - // Grab the current index - while ($currentIndex = array_shift($indexes)) { - if ($currentIndex === '*') { - $currentIndex = array_shift($indexes); - - foreach ($currentArray as $item) { - if (! array_key_exists($currentIndex, $item)) { - return false; - } - } + /** + * Recursively check key existence by dot path, including wildcard support. + * + * @param array $array + * @param list $indexes + */ + private static function hasByDotPath(array $array, array $indexes): bool + { + if ($indexes === []) { + return true; + } + + $currentIndex = array_shift($indexes); - // If indexes is empty, all elements are checked. - if ($indexes === []) { - return true; + if ($currentIndex === '*') { + foreach ($array as $item) { + if (! is_array($item) || ! self::hasByDotPath($item, $indexes)) { + return false; } + } + + return true; + } + + if (! array_key_exists($currentIndex, $array)) { + return false; + } + + if ($indexes === []) { + return true; + } + + if (! is_array($array[$currentIndex])) { + return false; + } + + return self::hasByDotPath($array[$currentIndex], $indexes); + } + + /** + * Sets a value by dot array syntax. + * + * @param array $array + */ + public static function dotSet(array &$array, string $index, mixed $value): void + { + self::ensureValidWildcardPattern($index); + + $indexes = self::convertToArray($index); + + if ($indexes === []) { + return; + } + + self::setByDotPath($array, $indexes, $value); + } - $currentArray = self::dotSearch('*.' . $currentIndex, $currentArray); + /** + * Removes a value by dot array syntax. + * + * @param array $array + */ + public static function dotUnset(array &$array, string $index): bool + { + self::ensureValidWildcardPattern($index, true); + + if ($index === '*') { + return self::clearByDotPath($array, []) > 0; + } + + $indexes = self::convertToArray($index); + + if ($indexes === []) { + return false; + } + + if (str_ends_with($index, '*')) { + return self::clearByDotPath($array, $indexes) > 0; + } + + return self::unsetByDotPath($array, $indexes) > 0; + } + + /** + * Gets only the specified keys using dot syntax. + * + * @param array $array + * @param list|string $indexes + * + * @return array + */ + public static function dotOnly(array $array, array|string $indexes): array + { + $indexes = is_string($indexes) ? [$indexes] : $indexes; + $result = []; + + foreach ($indexes as $index) { + self::ensureValidWildcardPattern($index, true); + + if ($index === '*') { + $result = [...$result, ...$array]; + + continue; + } + $segments = self::convertToArray($index); + if ($segments === []) { continue; } - if (! array_key_exists($currentIndex, $currentArray)) { - return false; + self::projectByDotPath($array, $segments, $result); + } + + return $result; + } + + /** + * Gets all keys except the specified ones using dot syntax. + * + * @param array $array + * @param list|string $indexes + * + * @return array + */ + public static function dotExcept(array $array, array|string $indexes): array + { + $indexes = is_string($indexes) ? [$indexes] : $indexes; + $result = $array; + + foreach ($indexes as $index) { + self::ensureValidWildcardPattern($index, true); + + if ($index === '*') { + $result = []; + + continue; + } + + if (str_ends_with($index, '*')) { + $segments = self::convertToArray($index); + self::clearByDotPath($result, $segments); + + continue; } - $currentArray = $currentArray[$currentIndex]; + $segments = self::convertToArray($index); + if ($segments !== []) { + self::unsetByDotPath($result, $segments); + } } - return true; + return $result; } /** @@ -315,4 +446,181 @@ public static function sortValuesByNatural(array &$array, $sortByIndex = null): return strnatcmp((string) $currentValue, (string) $nextValue); }); } + + /** + * Throws exception for invalid wildcard patterns. + */ + private static function ensureValidWildcardPattern(string $index, bool $allowTrailingWildcard = false): void + { + if ((! $allowTrailingWildcard && str_ends_with($index, '*')) || str_contains($index, '*.*')) { + throw new InvalidArgumentException( + 'You must set key right after "*". Invalid index: "' . $index . '"', + ); + } + } + + /** + * Set value recursively by dot path, including wildcard support. + * + * @param array $array + * @param list $indexes + */ + private static function setByDotPath(array &$array, array $indexes, mixed $value): void + { + if ($indexes === []) { + return; + } + + $currentIndex = array_shift($indexes); + + if ($currentIndex === '*') { + foreach ($array as &$item) { + if (! is_array($item)) { + continue; + } + + self::setByDotPath($item, $indexes, $value); + } + unset($item); + + return; + } + + if ($indexes === []) { + $array[$currentIndex] = $value; + + return; + } + + if (! isset($array[$currentIndex]) || ! is_array($array[$currentIndex])) { + $array[$currentIndex] = []; + } + + self::setByDotPath($array[$currentIndex], $indexes, $value); + } + + /** + * Unset value recursively by dot path, including wildcard support. + * + * @param array $array + * @param list $indexes + */ + private static function unsetByDotPath(array &$array, array $indexes): int + { + if ($indexes === []) { + return 0; + } + + $currentIndex = array_shift($indexes); + + if ($currentIndex === '*') { + $removed = 0; + + foreach ($array as &$item) { + if (! is_array($item)) { + continue; + } + + $removed += self::unsetByDotPath($item, $indexes); + } + unset($item); + + return $removed; + } + + if ($indexes === []) { + if (! array_key_exists($currentIndex, $array)) { + return 0; + } + + unset($array[$currentIndex]); + + return 1; + } + + if (! isset($array[$currentIndex]) || ! is_array($array[$currentIndex])) { + return 0; + } + + return self::unsetByDotPath($array[$currentIndex], $indexes); + } + + /** + * Clears all children under the specified path. + * + * @param array $array + * @param list $indexes + */ + private static function clearByDotPath(array &$array, array $indexes): int + { + if ($indexes === []) { + $count = count($array); + $array = []; + + return $count; + } + + $currentIndex = array_shift($indexes); + + if ($currentIndex === '*') { + $cleared = 0; + + foreach ($array as &$item) { + if (! is_array($item)) { + continue; + } + + $cleared += self::clearByDotPath($item, $indexes); + } + unset($item); + + return $cleared; + } + + if (! array_key_exists($currentIndex, $array) || ! is_array($array[$currentIndex])) { + return 0; + } + + return self::clearByDotPath($array[$currentIndex], $indexes); + } + + /** + * Projects matching paths from source array into result with preserved structure. + * + * @param list $indexes + * @param list $prefix + * @param array $result + */ + private static function projectByDotPath( + mixed $source, + array $indexes, + array &$result, + array $prefix = [], + ): void { + if ($indexes === []) { + self::setByDotPath($result, $prefix, $source); + + return; + } + + $currentIndex = array_shift($indexes); + + if ($currentIndex === '*') { + if (! is_array($source)) { + return; + } + + foreach ($source as $key => $value) { + self::projectByDotPath($value, $indexes, $result, [...$prefix, (string) $key]); + } + + return; + } + + if (! is_array($source) || ! array_key_exists($currentIndex, $source)) { + return; + } + + self::projectByDotPath($source[$currentIndex], $indexes, $result, [...$prefix, $currentIndex]); + } } diff --git a/system/Helpers/array_helper.php b/system/Helpers/array_helper.php index 837a612e4cef..4f24674397e4 100644 --- a/system/Helpers/array_helper.php +++ b/system/Helpers/array_helper.php @@ -28,6 +28,72 @@ function dot_array_search(string $index, array $array) } } +if (! function_exists('dot_array_has')) { + /** + * Checks if an array key exists using dot syntax. + * + * @param array $array + */ + function dot_array_has(string $index, array $array): bool + { + return ArrayHelper::dotHas($index, $array); + } +} + +if (! function_exists('dot_array_set')) { + /** + * Sets an array value using dot syntax. + * + * @param array $array + */ + function dot_array_set(array &$array, string $index, mixed $value): void + { + ArrayHelper::dotSet($array, $index, $value); + } +} + +if (! function_exists('dot_array_unset')) { + /** + * Unsets an array value using dot syntax. + * + * @param array $array + */ + function dot_array_unset(array &$array, string $index): bool + { + return ArrayHelper::dotUnset($array, $index); + } +} + +if (! function_exists('dot_array_only')) { + /** + * Gets only the specified keys using dot syntax. + * + * @param array $array + * @param list|string $indexes + * + * @return array + */ + function dot_array_only(array $array, array|string $indexes): array + { + return ArrayHelper::dotOnly($array, $indexes); + } +} + +if (! function_exists('dot_array_except')) { + /** + * Gets all keys except the specified ones using dot syntax. + * + * @param array $array + * @param list|string $indexes + * + * @return array + */ + function dot_array_except(array $array, array|string $indexes): array + { + return ArrayHelper::dotExcept($array, $indexes); + } +} + if (! function_exists('array_deep_search')) { /** * Returns the value of an element at a key in an array of uncertain depth. diff --git a/system/Validation/Rules.php b/system/Validation/Rules.php index e3e256b099b4..7c13afb7447c 100644 --- a/system/Validation/Rules.php +++ b/system/Validation/Rules.php @@ -460,7 +460,7 @@ public function field_exists( ?string $field = null, ): bool { if (str_contains($field, '.')) { - return ArrayHelper::dotKeyExists($field, $data); + return ArrayHelper::dotHas($field, $data); } return array_key_exists($field, $data); diff --git a/system/Validation/StrictRules/Rules.php b/system/Validation/StrictRules/Rules.php index 0a03e6461dba..c6489d31e05f 100644 --- a/system/Validation/StrictRules/Rules.php +++ b/system/Validation/StrictRules/Rules.php @@ -52,7 +52,7 @@ public function differs( } if (str_contains($field, '.')) { - if (! ArrayHelper::dotKeyExists($field, $data)) { + if (! ArrayHelper::dotHas($field, $data)) { return false; } } elseif (! array_key_exists($field, $data)) { @@ -245,7 +245,7 @@ public function matches( } if (str_contains($field, '.')) { - if (! ArrayHelper::dotKeyExists($field, $data)) { + if (! ArrayHelper::dotHas($field, $data)) { return false; } } elseif (! array_key_exists($field, $data)) { @@ -386,7 +386,7 @@ public function field_exists( ?string $field = null, ): bool { if (str_contains($field, '.')) { - return ArrayHelper::dotKeyExists($field, $data); + return ArrayHelper::dotHas($field, $data); } return array_key_exists($field, $data); diff --git a/tests/system/Helpers/Array/ArrayHelperDotHasTest.php b/tests/system/Helpers/Array/ArrayHelperDotHasTest.php new file mode 100644 index 000000000000..4b8b4751123a --- /dev/null +++ b/tests/system/Helpers/Array/ArrayHelperDotHasTest.php @@ -0,0 +1,74 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Helpers\Array; + +use CodeIgniter\Exceptions\InvalidArgumentException; +use CodeIgniter\Test\CIUnitTestCase; +use PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[Group('Others')] +final class ArrayHelperDotHasTest extends CIUnitTestCase +{ + private array $array = [ + 'contacts' => [ + 'friends' => [ + ['name' => 'Fred Flinstone', 'age' => 20], + ['age' => 21], // 'name' key does not exist + ], + ], + ]; + + public function testDotHas(): void + { + $this->assertFalse(ArrayHelper::dotHas('', $this->array)); + $this->assertTrue(ArrayHelper::dotHas('contacts', $this->array)); + $this->assertFalse(ArrayHelper::dotHas('not', $this->array)); + $this->assertTrue(ArrayHelper::dotHas('contacts.friends', $this->array)); + $this->assertFalse(ArrayHelper::dotHas('not.friends', $this->array)); + $this->assertTrue(ArrayHelper::dotHas('contacts.friends.0.name', $this->array)); + $this->assertFalse(ArrayHelper::dotHas('contacts.friends.1.name', $this->array)); + } + + public function testDotHasWithEndingWildCard(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('You must set key right after "*". Invalid index: "contacts.*"'); + + $this->assertTrue(ArrayHelper::dotHas('contacts.*', $this->array)); + } + + public function testDotHasWithDoubleWildCard(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('You must set key right after "*". Invalid index: "contacts.*.*.age"'); + + $this->assertTrue(ArrayHelper::dotHas('contacts.*.*.age', $this->array)); + } + + public function testDotHasWithWildCard(): void + { + $this->assertTrue(ArrayHelper::dotHas('*.friends', $this->array)); + $this->assertTrue(ArrayHelper::dotHas('contacts.friends.*.age', $this->array)); + $this->assertFalse(ArrayHelper::dotHas('contacts.friends.*.name', $this->array)); + $this->assertTrue(ArrayHelper::dotHas('*.friends.*.age', $this->array)); + $this->assertFalse(ArrayHelper::dotHas('*.friends.*.name', $this->array)); + $this->assertTrue(ArrayHelper::dotHas('contacts.*.0.age', $this->array)); + $this->assertTrue(ArrayHelper::dotHas('contacts.*.1.age', $this->array)); + $this->assertTrue(ArrayHelper::dotHas('contacts.*.0.name', $this->array)); + $this->assertFalse(ArrayHelper::dotHas('contacts.*.1.name', $this->array)); + } +} diff --git a/tests/system/Helpers/Array/ArrayHelperDotKeyExistsTest.php b/tests/system/Helpers/Array/ArrayHelperDotKeyExistsTest.php deleted file mode 100644 index 0641c41b2bd0..000000000000 --- a/tests/system/Helpers/Array/ArrayHelperDotKeyExistsTest.php +++ /dev/null @@ -1,74 +0,0 @@ - - * - * For the full copyright and license information, please view - * the LICENSE file that was distributed with this source code. - */ - -namespace CodeIgniter\Helpers\Array; - -use CodeIgniter\Exceptions\InvalidArgumentException; -use CodeIgniter\Test\CIUnitTestCase; -use PHPUnit\Framework\Attributes\Group; - -/** - * @internal - */ -#[Group('Others')] -final class ArrayHelperDotKeyExistsTest extends CIUnitTestCase -{ - private array $array = [ - 'contacts' => [ - 'friends' => [ - ['name' => 'Fred Flinstone', 'age' => 20], - ['age' => 21], // 'name' key does not exist - ], - ], - ]; - - public function testDotKeyExists(): void - { - $this->assertFalse(ArrayHelper::dotKeyExists('', $this->array)); - $this->assertTrue(ArrayHelper::dotKeyExists('contacts', $this->array)); - $this->assertFalse(ArrayHelper::dotKeyExists('not', $this->array)); - $this->assertTrue(ArrayHelper::dotKeyExists('contacts.friends', $this->array)); - $this->assertFalse(ArrayHelper::dotKeyExists('not.friends', $this->array)); - $this->assertTrue(ArrayHelper::dotKeyExists('contacts.friends.0.name', $this->array)); - $this->assertFalse(ArrayHelper::dotKeyExists('contacts.friends.1.name', $this->array)); - } - - public function testDotKeyExistsWithEndingWildCard(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('You must set key right after "*". Invalid index: "contacts.*"'); - - $this->assertTrue(ArrayHelper::dotKeyExists('contacts.*', $this->array)); - } - - public function testDotKeyExistsWithDoubleWildCard(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('You must set key right after "*". Invalid index: "contacts.*.*.age"'); - - $this->assertTrue(ArrayHelper::dotKeyExists('contacts.*.*.age', $this->array)); - } - - public function testDotKeyExistsWithWildCard(): void - { - $this->assertTrue(ArrayHelper::dotKeyExists('*.friends', $this->array)); - $this->assertTrue(ArrayHelper::dotKeyExists('contacts.friends.*.age', $this->array)); - $this->assertFalse(ArrayHelper::dotKeyExists('contacts.friends.*.name', $this->array)); - $this->assertTrue(ArrayHelper::dotKeyExists('*.friends.*.age', $this->array)); - $this->assertFalse(ArrayHelper::dotKeyExists('*.friends.*.name', $this->array)); - $this->assertTrue(ArrayHelper::dotKeyExists('contacts.*.0.age', $this->array)); - $this->assertTrue(ArrayHelper::dotKeyExists('contacts.*.1.age', $this->array)); - $this->assertTrue(ArrayHelper::dotKeyExists('contacts.*.0.name', $this->array)); - $this->assertFalse(ArrayHelper::dotKeyExists('contacts.*.1.name', $this->array)); - } -} diff --git a/tests/system/Helpers/Array/ArrayHelperDotModifyTest.php b/tests/system/Helpers/Array/ArrayHelperDotModifyTest.php new file mode 100644 index 000000000000..5560703344c9 --- /dev/null +++ b/tests/system/Helpers/Array/ArrayHelperDotModifyTest.php @@ -0,0 +1,490 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Helpers\Array; + +use CodeIgniter\Exceptions\InvalidArgumentException; +use CodeIgniter\Test\CIUnitTestCase; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[Group('Others')] +final class ArrayHelperDotModifyTest extends CIUnitTestCase +{ + public function testDotSetCreatesNestedPath(): void + { + $array = []; + + ArrayHelper::dotSet($array, 'user.profile.id', 123); + + $this->assertSame(['user' => ['profile' => ['id' => 123]]], $array); + } + + public function testDotSetOverwritesLeafValue(): void + { + $array = ['user' => ['profile' => ['id' => 123]]]; + + ArrayHelper::dotSet($array, 'user.profile.id', 456); + + $this->assertSame(456, $array['user']['profile']['id']); + } + + public function testDotSetWithEscapedDotKey(): void + { + $array = []; + + ArrayHelper::dotSet($array, 'config.api\.version', 'v1'); + + $this->assertSame('v1', $array['config']['api.version']); + } + + /** + * @param array $array + */ + #[DataProvider('provideDotHas')] + public function testDotHas(string $index, array $array, bool $expected): void + { + $this->assertSame($expected, ArrayHelper::dotHas($index, $array)); + } + + /** + * @return iterable, expected: bool}> + */ + public static function provideDotHas(): iterable + { + yield from [ + 'null value at leaf' => [ + 'index' => 'user.nickname', + 'array' => ['user' => ['nickname' => null]], + 'expected' => true, + ], + 'path does not exist' => [ + 'index' => 'user.email', + 'array' => ['user' => ['id' => 123]], + 'expected' => false, + ], + 'non-existent numeric key' => [ + 'index' => '0.name', + 'array' => ['other' => 'x'], + 'expected' => false, + ], + 'existing numeric key' => [ + 'index' => '0.name', + 'array' => [['name' => 'a']], + 'expected' => true, + ], + 'zero value at leaf' => [ + 'index' => 'user.score', + 'array' => ['user' => ['score' => 0]], + 'expected' => true, + ], + 'string zero at leaf' => [ + 'index' => 'user.code', + 'array' => ['user' => ['code' => '0']], + 'expected' => true, + ], + 'escaped dot in key' => [ + 'index' => 'config.api\.version', + 'array' => ['config' => ['api.version' => 'v1']], + 'expected' => true, + ], + 'escaped dot key does not exist' => [ + 'index' => 'config.api\.version', + 'array' => ['config' => ['api' => ['version' => 'v1']]], + 'expected' => false, + ], + ]; + } + + public function testDotHasSupportsWildcard(): void + { + $array = [ + 'users' => [ + ['id' => 1, 'name' => 'a'], + ['id' => 2, 'name' => 'b'], + ], + ]; + + $this->assertTrue(ArrayHelper::dotHas('users.*.id', $array)); + $this->assertFalse(ArrayHelper::dotHas('users.*.email', $array)); + } + + public function testDotSetSupportsWildcard(): void + { + $array = [ + 'users' => [ + ['id' => 1, 'name' => 'a'], + ['id' => 2, 'name' => 'b'], + ], + ]; + + ArrayHelper::dotSet($array, 'users.*.role', 'member'); + + $this->assertSame('member', $array['users'][0]['role']); + $this->assertSame('member', $array['users'][1]['role']); + } + + public function testDotSetSupportsWildcardSkipsNonArrayElements(): void + { + $array = [ + 'users' => [ + ['name' => 'a'], + 'invalid-entry', + ['name' => 'b'], + ], + ]; + + ArrayHelper::dotSet($array, 'users.*.role', 'member'); + + $this->assertSame('member', $array['users'][0]['role']); + $this->assertSame('invalid-entry', $array['users'][1]); + $this->assertSame('member', $array['users'][2]['role']); + } + + public function testDotUnsetRemovesNestedValue(): void + { + $array = ['user' => ['profile' => ['id' => 123, 'name' => 'john']]]; + + $this->assertTrue(ArrayHelper::dotUnset($array, 'user.profile.id')); + + $this->assertFalse(ArrayHelper::dotHas('user.profile.id', $array)); + $this->assertSame('john', $array['user']['profile']['name']); + } + + public function testDotUnsetIsNoOpWhenPathDoesNotExist(): void + { + $array = ['user' => ['id' => 123]]; + + $this->assertFalse(ArrayHelper::dotUnset($array, 'user.profile.id')); + + $this->assertSame(['user' => ['id' => 123]], $array); + } + + public function testDotUnsetWithEscapedDotKey(): void + { + $array = ['config' => ['api.version' => 'v1', 'region' => 'eu']]; + + $this->assertTrue(ArrayHelper::dotUnset($array, 'config.api\.version')); + + $this->assertSame(['config' => ['region' => 'eu']], $array); + } + + public function testDotUnsetSupportsWildcard(): void + { + $array = [ + 'users' => [ + ['id' => 1, 'name' => 'a'], + ['id' => 2, 'name' => 'b'], + ], + ]; + + $this->assertTrue(ArrayHelper::dotUnset($array, 'users.*.id')); + $this->assertFalse(ArrayHelper::dotHas('users.*.id', $array)); + $this->assertSame('a', $array['users'][0]['name']); + $this->assertSame('b', $array['users'][1]['name']); + } + + public function testDotUnsetSupportsWildcardReturnsFalseWhenNoKeysRemoved(): void + { + $array = [ + 'users' => [ + ['name' => 'a'], + ['name' => 'b'], + ], + ]; + + $this->assertFalse(ArrayHelper::dotUnset($array, 'users.*.id')); + } + + public function testDotUnsetSupportsEndingWildcard(): void + { + $array = [ + 'user' => [ + 'id' => 123, + 'name' => 'john', + ], + 'meta' => ['request_id' => 'abc'], + ]; + + $this->assertTrue(ArrayHelper::dotUnset($array, 'user.*')); + $this->assertSame(['user' => [], 'meta' => ['request_id' => 'abc']], $array); + } + + public function testDotUnsetWithSingleWildcardClearsWholeArray(): void + { + $array = [ + 'user' => ['id' => 123], + 'meta' => ['request_id' => 'abc'], + ]; + + $this->assertTrue(ArrayHelper::dotUnset($array, '*')); + $this->assertSame([], $array); + } + + public function testDotSetThrowsExceptionForInvalidWildcardPattern(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('You must set key right after "*". Invalid index: "users.*"'); + + $array = []; + ArrayHelper::dotSet($array, 'users.*', 1); + } + + public function testDotHasThrowsExceptionForInvalidWildcardPattern(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('You must set key right after "*". Invalid index: "users.*"'); + + ArrayHelper::dotHas('users.*', []); + } + + public function testDotUnsetThrowsExceptionForInvalidWildcardPattern(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('You must set key right after "*". Invalid index: "users.*.*.id"'); + + $array = []; + ArrayHelper::dotUnset($array, 'users.*.*.id'); + } + + public function testDotOnlyReturnsNestedStructureForSinglePath(): void + { + $array = [ + 'user' => [ + 'id' => 123, + 'name' => 'john', + ], + 'meta' => ['request_id' => 'abc'], + ]; + + $expected = [ + 'user' => [ + 'id' => 123, + ], + ]; + + $this->assertSame($expected, ArrayHelper::dotOnly($array, 'user.id')); + } + + public function testDotOnlyMergesMultiplePaths(): void + { + $array = [ + 'user' => [ + 'id' => 123, + 'name' => 'john', + 'email' => 'john@example.com', + ], + ]; + + $expected = [ + 'user' => [ + 'id' => 123, + 'name' => 'john', + ], + ]; + + $this->assertSame($expected, ArrayHelper::dotOnly($array, ['user.id', 'user.name'])); + } + + public function testDotOnlySupportsWildcard(): void + { + $array = [ + 'users' => [ + ['id' => 1, 'name' => 'a'], + ['id' => 2, 'name' => 'b'], + ], + ]; + + $expected = [ + 'users' => [ + ['id' => 1], + ['id' => 2], + ], + ]; + + $this->assertSame($expected, ArrayHelper::dotOnly($array, 'users.*.id')); + } + + public function testDotOnlySupportsEndingWildcard(): void + { + $array = [ + 'user' => [ + 'id' => 123, + 'name' => 'john', + ], + 'meta' => ['request_id' => 'abc'], + ]; + + $expected = [ + 'user' => [ + 'id' => 123, + 'name' => 'john', + ], + ]; + + $this->assertSame($expected, ArrayHelper::dotOnly($array, 'user.*')); + } + + public function testDotOnlySupportsEscapedDotKey(): void + { + $array = [ + 'config' => [ + 'api.version' => 'v1', + 'region' => 'eu', + ], + ]; + + $expected = [ + 'config' => [ + 'api.version' => 'v1', + ], + ]; + + $this->assertSame($expected, ArrayHelper::dotOnly($array, 'config.api\.version')); + } + + public function testDotExceptRemovesNestedPath(): void + { + $array = [ + 'user' => [ + 'id' => 123, + 'name' => 'john', + ], + 'meta' => ['request_id' => 'abc'], + ]; + + $expected = [ + 'user' => [ + 'name' => 'john', + ], + 'meta' => ['request_id' => 'abc'], + ]; + + $this->assertSame($expected, ArrayHelper::dotExcept($array, 'user.id')); + } + + public function testDotExceptSupportsWildcard(): void + { + $array = [ + 'users' => [ + ['id' => 1, 'name' => 'a'], + ['id' => 2, 'name' => 'b'], + ], + ]; + + $expected = [ + 'users' => [ + ['name' => 'a'], + ['name' => 'b'], + ], + ]; + + $this->assertSame($expected, ArrayHelper::dotExcept($array, 'users.*.id')); + } + + public function testDotExceptSupportsEndingWildcard(): void + { + $array = [ + 'user' => [ + 'id' => 123, + 'name' => 'john', + ], + 'meta' => ['request_id' => 'abc'], + ]; + + $expected = [ + 'user' => [], + 'meta' => ['request_id' => 'abc'], + ]; + + $this->assertSame($expected, ArrayHelper::dotExcept($array, 'user.*')); + } + + public function testDotExceptWithEscapedDotKey(): void + { + $array = [ + 'config' => [ + 'api.version' => 'v1', + 'region' => 'eu', + ], + ]; + + $expected = [ + 'config' => [ + 'region' => 'eu', + ], + ]; + + $this->assertSame($expected, ArrayHelper::dotExcept($array, 'config.api\.version')); + } + + public function testDotOnlyWithSingleWildcardReturnsWholeArray(): void + { + $array = [ + 'user' => ['id' => 123], + 'meta' => ['request_id' => 'abc'], + ]; + + $this->assertSame($array, ArrayHelper::dotOnly($array, '*')); + } + + public function testDotExceptWithSingleWildcardReturnsEmptyArray(): void + { + $array = [ + 'user' => ['id' => 123], + 'meta' => ['request_id' => 'abc'], + ]; + + $this->assertSame([], ArrayHelper::dotExcept($array, '*')); + } + + public function testDotSetWithNumericKey(): void + { + $array = [['name' => 'a'], ['name' => 'b']]; + + ArrayHelper::dotSet($array, '0.name', 'x'); + + $this->assertSame('x', $array[0]['name']); + $this->assertSame('b', $array[1]['name']); + } + + public function testDotUnsetWithNumericKey(): void + { + $array = [['name' => 'a', 'role' => 'admin'], ['name' => 'b']]; + + $this->assertTrue(ArrayHelper::dotUnset($array, '0.role')); + $this->assertFalse(ArrayHelper::dotHas('0.role', $array)); + $this->assertSame('a', $array[0]['name']); + } + + public function testDotOnlyWithNumericKey(): void + { + $array = [['name' => 'a', 'role' => 'admin'], ['name' => 'b']]; + + $expected = [['name' => 'a']]; + + $this->assertSame($expected, ArrayHelper::dotOnly($array, '0.name')); + } + + public function testDotExceptWithNumericKey(): void + { + $array = [['name' => 'a', 'role' => 'admin'], ['name' => 'b']]; + + $expected = [['name' => 'a'], ['name' => 'b']]; + + $this->assertSame($expected, ArrayHelper::dotExcept($array, '0.role')); + } +} diff --git a/tests/system/Helpers/ArrayHelperTest.php b/tests/system/Helpers/ArrayHelperTest.php index 0293cc6c826e..8cbf62235835 100644 --- a/tests/system/Helpers/ArrayHelperTest.php +++ b/tests/system/Helpers/ArrayHelperTest.php @@ -13,6 +13,7 @@ namespace CodeIgniter\Helpers; +use CodeIgniter\Exceptions\InvalidArgumentException; use CodeIgniter\Test\CIUnitTestCase; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; @@ -41,6 +42,176 @@ public function testArrayDotSimple(): void $this->assertSame(23, dot_array_search('foo.bar', $data)); } + public function testDotArraySetAndHas(): void + { + $data = []; + + dot_array_set($data, 'foo.bar', 23); + + $this->assertSame(['foo' => ['bar' => 23]], $data); + $this->assertTrue(dot_array_has('foo.bar', $data)); + } + + public function testDotArraySetWithWildcard(): void + { + $data = [ + 'foo' => [ + ['bar' => 23], + ['bar' => 42], + ], + ]; + + dot_array_set($data, 'foo.*.baz', 99); + + $this->assertSame(99, $data['foo'][0]['baz']); + $this->assertSame(99, $data['foo'][1]['baz']); + } + + public function testDotArrayHasSupportsWildcard(): void + { + $data = [ + 'foo' => [ + ['bar' => 23], + ['bar' => 42], + ], + ]; + + $this->assertTrue(dot_array_has('foo.*.bar', $data)); + } + + public function testDotArrayUnset(): void + { + $data = ['foo' => ['bar' => 23, 'baz' => 42]]; + + $this->assertTrue(dot_array_unset($data, 'foo.bar')); + + $this->assertFalse(dot_array_has('foo.bar', $data)); + $this->assertTrue(dot_array_has('foo.baz', $data)); + } + + public function testDotArrayUnsetWithWildcard(): void + { + $data = [ + 'foo' => [ + ['bar' => 23, 'baz' => 1], + ['bar' => 42, 'baz' => 2], + ], + ]; + + $this->assertTrue(dot_array_unset($data, 'foo.*.bar')); + $this->assertFalse(dot_array_has('foo.*.bar', $data)); + $this->assertTrue(dot_array_has('foo.*.baz', $data)); + } + + public function testDotArrayUnsetSupportsEndingWildcard(): void + { + $data = [ + 'foo' => [ + 'bar' => 23, + 'baz' => 42, + ], + 'meta' => ['request_id' => 'abc'], + ]; + + $this->assertTrue(dot_array_unset($data, 'foo.*')); + $this->assertSame(['foo' => [], 'meta' => ['request_id' => 'abc']], $data); + } + + public function testDotArraySetThrowsExceptionForInvalidWildcardPattern(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('You must set key right after "*". Invalid index: "users.*"'); + + $data = []; + dot_array_set($data, 'users.*', 'member'); + } + + public function testDotArrayUnsetThrowsExceptionForInvalidWildcardPattern(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('You must set key right after "*". Invalid index: "users.*.*.id"'); + + $data = []; + dot_array_unset($data, 'users.*.*.id'); + } + + public function testDotArrayOnly(): void + { + $data = [ + 'user' => [ + 'id' => 123, + 'name' => 'john', + ], + 'meta' => ['request_id' => 'abc'], + ]; + + $expected = [ + 'user' => [ + 'id' => 123, + ], + ]; + + $this->assertSame($expected, dot_array_only($data, 'user.id')); + } + + public function testDotArrayOnlySupportsEndingWildcard(): void + { + $data = [ + 'user' => [ + 'id' => 123, + 'name' => 'john', + ], + 'meta' => ['request_id' => 'abc'], + ]; + + $expected = [ + 'user' => [ + 'id' => 123, + 'name' => 'john', + ], + ]; + + $this->assertSame($expected, dot_array_only($data, 'user.*')); + } + + public function testDotArrayExcept(): void + { + $data = [ + 'user' => [ + 'id' => 123, + 'name' => 'john', + ], + 'meta' => ['request_id' => 'abc'], + ]; + + $expected = [ + 'user' => [ + 'name' => 'john', + ], + 'meta' => ['request_id' => 'abc'], + ]; + + $this->assertSame($expected, dot_array_except($data, 'user.id')); + } + + public function testDotArrayExceptSupportsEndingWildcard(): void + { + $data = [ + 'user' => [ + 'id' => 123, + 'name' => 'john', + ], + 'meta' => ['request_id' => 'abc'], + ]; + + $expected = [ + 'user' => [], + 'meta' => ['request_id' => 'abc'], + ]; + + $this->assertSame($expected, dot_array_except($data, 'user.*')); + } + public function testArrayDotTooManyLevels(): void { $data = [ @@ -274,6 +445,62 @@ public function testArrayDeepSearchReturnNullEmptyArray(): void $this->assertNull(array_deep_search('key644', $data)); } + /** + * @param array $data + */ + #[DataProvider('provideDotArrayHas')] + public function testDotArrayHas(string $index, array $data, bool $expected): void + { + $this->assertSame($expected, dot_array_has($index, $data)); + } + + /** + * @return iterable, expected: bool}> + */ + public static function provideDotArrayHas(): iterable + { + yield from [ + 'non-existent numeric key' => [ + 'index' => '0.name', + 'data' => ['other' => 'x'], + 'expected' => false, + ], + 'existing numeric key' => [ + 'index' => '0.name', + 'data' => [['name' => 'a']], + 'expected' => true, + ], + 'null value at leaf' => [ + 'index' => 'user.score', + 'data' => ['user' => ['score' => null]], + 'expected' => true, + ], + 'zero value at leaf' => [ + 'index' => 'user.score', + 'data' => ['user' => ['score' => 0]], + 'expected' => true, + ], + 'escaped dot in key' => [ + 'index' => 'config.api\.version', + 'data' => ['config' => ['api.version' => 'v1']], + 'expected' => true, + ], + ]; + } + + public function testDotArraySetAndUnsetWithNumericKey(): void + { + $data = [['name' => 'a'], ['name' => 'b']]; + + dot_array_set($data, '0.role', 'admin'); + + $this->assertSame('admin', $data[0]['role']); + $this->assertFalse(dot_array_has('1.role', $data)); + + $this->assertTrue(dot_array_unset($data, '0.role')); + $this->assertFalse(dot_array_has('0.role', $data)); + } + #[DataProvider('provideSortByMultipleKeys')] public function testArraySortByMultipleKeysWithArray(array $data, array $sortColumns, array $expected): void { diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index bcb0feca9e00..c889d7ff8016 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -145,6 +145,10 @@ Libraries Helpers and Functions ===================== +- :doc:`Array Helper ` gained five new dot-path functions: + :php:func:`dot_array_has()`, :php:func:`dot_array_set()`, :php:func:`dot_array_unset()`, + :php:func:`dot_array_only()`, and :php:func:`dot_array_except()`. + HTTP ==== diff --git a/user_guide_src/source/helpers/array_helper.rst b/user_guide_src/source/helpers/array_helper.rst index 8a9c795d335f..fd13e0ae6f6f 100644 --- a/user_guide_src/source/helpers/array_helper.rst +++ b/user_guide_src/source/helpers/array_helper.rst @@ -56,6 +56,87 @@ The following functions are available: .. note:: Prior to v4.2.0, ``dot_array_search('foo.bar.baz', ['foo' => ['bar' => 23]])`` returned ``23`` due to a bug. v4.2.0 and later returns ``null``. +.. php:function:: dot_array_has(string $search, array $values): bool + + :param string $search: The dot-notation string describing how to search the array + :param array $values: The array to check + :returns: ``true`` if the key exists, otherwise ``false`` + :rtype: bool + + .. versionadded:: 4.8.0 + + Checks if an array key exists using dot syntax. + This method supports wildcard ``*`` in the same way as ``dot_array_search()``. + + .. literalinclude:: array_helper/015.php + :lines: 2- + +.. php:function:: dot_array_set(array &$array, string $search, mixed $value): void + + :param array $array: The array to modify (passed by reference) + :param string $search: The dot-notation string describing where to set the value + :param mixed $value: The value to set + :rtype: void + + .. versionadded:: 4.8.0 + + Sets an array value using dot syntax. Missing path segments are created automatically. + Wildcard ``*`` is supported with the same rule as ``dot_array_has()``: + you must specify a key right after ``*``. + + .. literalinclude:: array_helper/016.php + :lines: 2- + +.. php:function:: dot_array_unset(array &$array, string $search): bool + + :param array $array: The array to modify (passed by reference) + :param string $search: The dot-notation string describing which key to remove + :returns: ``true`` if a key was removed, otherwise ``false`` + :rtype: bool + + .. versionadded:: 4.8.0 + + Removes array values using dot syntax. + Wildcard ``*`` is supported. + You can target specific keys like ``users.*.id`` or clear all keys under a path with ``user.*``. + + .. literalinclude:: array_helper/017.php + :lines: 2- + +.. php:function:: dot_array_only(array $array, array|string $indexes): array + + :param array $array: The source array + :param array|string $indexes: One key or a list of keys using dot notation + :returns: Nested array containing only the requested keys + :rtype: array + + .. versionadded:: 4.8.0 + + Gets only the specified keys using dot syntax while preserving nested structure. + + Wildcard ``*`` is supported. Unlike ``dot_array_set()`` and ``dot_array_unset()``, + this method also allows wildcard at the end (for example ``user.*``). + + .. literalinclude:: array_helper/018.php + :lines: 2- + +.. php:function:: dot_array_except(array $array, array|string $indexes): array + + :param array $array: The source array + :param array|string $indexes: One key or a list of keys using dot notation + :returns: Nested array with the specified keys removed + :rtype: array + + .. versionadded:: 4.8.0 + + Gets all keys except the specified ones using dot syntax. + + Wildcard ``*`` is supported. Unlike ``dot_array_set()`` and ``dot_array_unset()``, + this method also allows wildcard at the end (for example ``user.*``). + + .. literalinclude:: array_helper/019.php + :lines: 2- + .. php:function:: array_deep_search($key, array $array) :param mixed $key: The target key diff --git a/user_guide_src/source/helpers/array_helper/015.php b/user_guide_src/source/helpers/array_helper/015.php new file mode 100644 index 000000000000..1fb143a56b56 --- /dev/null +++ b/user_guide_src/source/helpers/array_helper/015.php @@ -0,0 +1,16 @@ + [ + ['id' => 1, 'name' => 'Jane'], + ['id' => 2, 'name' => 'John'], + ], +]; + +// Returns: true (all matched users have an "id" key) +$hasIds = dot_array_has('users.*.id', $data); + +// If any user is missing "id", this would return false. + +// Returns: false +$hasEmails = dot_array_has('users.*.email', $data); diff --git a/user_guide_src/source/helpers/array_helper/016.php b/user_guide_src/source/helpers/array_helper/016.php new file mode 100644 index 000000000000..14662195e462 --- /dev/null +++ b/user_guide_src/source/helpers/array_helper/016.php @@ -0,0 +1,33 @@ + 'Jane'], + ['name' => 'John'], +]; + +dot_array_set($users, '*.active', true); + +/* +$data is now: +[ + 'user' => [ + 'profile' => [ + 'id' => 123, + 'name' => 'John', + ], + ], +] +*/ + +/* +$users is now: +[ + ['name' => 'Jane', 'active' => true], + ['name' => 'John', 'active' => true], +] +*/ diff --git a/user_guide_src/source/helpers/array_helper/017.php b/user_guide_src/source/helpers/array_helper/017.php new file mode 100644 index 000000000000..b14c086148f0 --- /dev/null +++ b/user_guide_src/source/helpers/array_helper/017.php @@ -0,0 +1,27 @@ + [ + 'profile' => [ + 'id' => 123, + 'name' => 'John', + ], + ], +]; + +// Returns: true +$removed = dot_array_unset($data, 'user.profile.id'); + +// Returns: false (path does not exist) +$removedAgain = dot_array_unset($data, 'user.profile.id'); + +$users = [ + ['id' => 1, 'name' => 'Jane'], + ['id' => 2, 'name' => 'John'], +]; + +// Returns: true (removes "id" from all user rows) +$removedIds = dot_array_unset($users, '*.id'); + +// Returns: true (clears all keys under "user") +$clearedUser = dot_array_unset($data, 'user.*'); diff --git a/user_guide_src/source/helpers/array_helper/018.php b/user_guide_src/source/helpers/array_helper/018.php new file mode 100644 index 000000000000..3672bf945383 --- /dev/null +++ b/user_guide_src/source/helpers/array_helper/018.php @@ -0,0 +1,33 @@ + [ + 'id' => 123, + 'name' => 'John', + 'email' => 'john@example.com', + ], + 'meta' => [ + 'request_id' => 'abc', + ], +]; + +$only = dot_array_only($data, ['user.id', 'meta.request_id']); +/* +$only: +[ + 'user' => ['id' => 123], + 'meta' => ['request_id' => 'abc'], +] +*/ + +$userOnly = dot_array_only($data, 'user.*'); +/* +$userOnly: +[ + 'user' => [ + 'id' => 123, + 'name' => 'John', + 'email' => 'john@example.com', + ], +] +*/ diff --git a/user_guide_src/source/helpers/array_helper/019.php b/user_guide_src/source/helpers/array_helper/019.php new file mode 100644 index 000000000000..96ab98ceebb1 --- /dev/null +++ b/user_guide_src/source/helpers/array_helper/019.php @@ -0,0 +1,33 @@ + [ + 'id' => 123, + 'name' => 'John', + 'email' => 'john@example.com', + ], + 'meta' => [ + 'request_id' => 'abc', + ], +]; + +$except = dot_array_except($data, ['user.email', 'meta.request_id']); +/* +$except: +[ + 'user' => [ + 'id' => 123, + 'name' => 'John', + ], + 'meta' => [], +] +*/ + +$clearUser = dot_array_except($data, 'user.*'); +/* +$clearUser: +[ + 'user' => [], + 'meta' => ['request_id' => 'abc'], +] +*/ diff --git a/utils/phpstan-baseline/missingType.iterableValue.neon b/utils/phpstan-baseline/missingType.iterableValue.neon index a584450fc456..6950c34aa970 100644 --- a/utils/phpstan-baseline/missingType.iterableValue.neon +++ b/utils/phpstan-baseline/missingType.iterableValue.neon @@ -3062,11 +3062,6 @@ parameters: count: 1 path: ../../system/Helpers/Array/ArrayHelper.php - - - message: '#^Method CodeIgniter\\Helpers\\Array\\ArrayHelper\:\:dotKeyExists\(\) has parameter \$array with no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Helpers/Array/ArrayHelper.php - - message: '#^Method CodeIgniter\\Helpers\\Array\\ArrayHelper\:\:dotSearch\(\) has parameter \$array with no value type specified in iterable type array\.$#' count: 1 @@ -5223,9 +5218,9 @@ parameters: path: ../../tests/system/HTTP/URITest.php - - message: '#^Property CodeIgniter\\Helpers\\Array\\ArrayHelperDotKeyExistsTest\:\:\$array type has no value type specified in iterable type array\.$#' + message: '#^Property CodeIgniter\\Helpers\\Array\\ArrayHelperDotHasTest\:\:\$array type has no value type specified in iterable type array\.$#' count: 1 - path: ../../tests/system/Helpers/Array/ArrayHelperDotKeyExistsTest.php + path: ../../tests/system/Helpers/Array/ArrayHelperDotHasTest.php - message: '#^Property CodeIgniter\\Helpers\\Array\\ArrayHelperRecursiveDiffTest\:\:\$compareWith type has no value type specified in iterable type array\.$#'