From 667e740be69b112c8e683333e3efbd62b924e105 Mon Sep 17 00:00:00 2001 From: michalsn Date: Sun, 22 Feb 2026 17:42:27 +0100 Subject: [PATCH 1/5] feat: add new dot-syntax helper functions --- system/Helpers/Array/ArrayHelper.php | 355 ++++++++++++-- system/Helpers/array_helper.php | 66 +++ system/Validation/Rules.php | 2 +- system/Validation/StrictRules/Rules.php | 6 +- ...istsTest.php => ArrayHelperDotHasTest.php} | 38 +- .../Array/ArrayHelperDotModifyTest.php | 462 ++++++++++++++++++ tests/system/Helpers/ArrayHelperTest.php | 222 +++++++++ .../missingType.iterableValue.neon | 9 +- 8 files changed, 1102 insertions(+), 58 deletions(-) rename tests/system/Helpers/Array/{ArrayHelperDotKeyExistsTest.php => ArrayHelperDotHasTest.php} (50%) create mode 100644 tests/system/Helpers/Array/ArrayHelperDotModifyTest.php diff --git a/system/Helpers/Array/ArrayHelper.php b/system/Helpers/Array/ArrayHelper.php index 6741dc4bbb0e..d26c25a55d3c 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 */ @@ -125,53 +126,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; + } - $currentArray = self::dotSearch('*.' . $currentIndex, $currentArray); + 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); + } + + /** + * 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; + } + + 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 (! array_key_exists($currentIndex, $currentArray)) { - return false; + 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 +437,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/ArrayHelperDotKeyExistsTest.php b/tests/system/Helpers/Array/ArrayHelperDotHasTest.php similarity index 50% rename from tests/system/Helpers/Array/ArrayHelperDotKeyExistsTest.php rename to tests/system/Helpers/Array/ArrayHelperDotHasTest.php index 0641c41b2bd0..47590d6b6426 100644 --- a/tests/system/Helpers/Array/ArrayHelperDotKeyExistsTest.php +++ b/tests/system/Helpers/Array/ArrayHelperDotHasTest.php @@ -21,7 +21,7 @@ * @internal */ #[Group('Others')] -final class ArrayHelperDotKeyExistsTest extends CIUnitTestCase +final class ArrayHelperDotHasTest extends CIUnitTestCase { private array $array = [ 'contacts' => [ @@ -34,13 +34,13 @@ final class ArrayHelperDotKeyExistsTest extends CIUnitTestCase 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)); + $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 testDotKeyExistsWithEndingWildCard(): void @@ -48,7 +48,7 @@ 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)); + $this->assertTrue(ArrayHelper::dotHas('contacts.*', $this->array)); } public function testDotKeyExistsWithDoubleWildCard(): void @@ -56,19 +56,19 @@ 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)); + $this->assertTrue(ArrayHelper::dotHas('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)); + $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/ArrayHelperDotModifyTest.php b/tests/system/Helpers/Array/ArrayHelperDotModifyTest.php new file mode 100644 index 000000000000..b8e8da685b52 --- /dev/null +++ b/tests/system/Helpers/Array/ArrayHelperDotModifyTest.php @@ -0,0 +1,462 @@ + + * + * 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('provideDotKeyExists')] + public function testDotKeyExists(string $index, array $array, bool $expected): void + { + $this->assertSame($expected, ArrayHelper::dotHas($index, $array)); + } + + /** + * @return iterable, expected: bool}> + */ + public static function provideDotKeyExists(): 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, + ], + ]; + } + + 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 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..15e0fa8889d3 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,57 @@ 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, + ], + ]; + } + + 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/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\.$#' From c422d73ea04776859dca3a35cee60cf22a09f3d1 Mon Sep 17 00:00:00 2001 From: michalsn Date: Sun, 22 Feb 2026 17:45:13 +0100 Subject: [PATCH 2/5] optimize --- system/Helpers/Array/ArrayHelper.php | 21 ++++++++++---- .../Array/ArrayHelperDotModifyTest.php | 28 +++++++++++++++++++ tests/system/Helpers/ArrayHelperTest.php | 5 ++++ 3 files changed, 48 insertions(+), 6 deletions(-) diff --git a/system/Helpers/Array/ArrayHelper.php b/system/Helpers/Array/ArrayHelper.php index d26c25a55d3c..1c33fbaef0d1 100644 --- a/system/Helpers/Array/ArrayHelper.php +++ b/system/Helpers/Array/ArrayHelper.php @@ -50,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), diff --git a/tests/system/Helpers/Array/ArrayHelperDotModifyTest.php b/tests/system/Helpers/Array/ArrayHelperDotModifyTest.php index b8e8da685b52..b5c80bbb6c83 100644 --- a/tests/system/Helpers/Array/ArrayHelperDotModifyTest.php +++ b/tests/system/Helpers/Array/ArrayHelperDotModifyTest.php @@ -96,6 +96,16 @@ public static function provideDotKeyExists(): iterable '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, + ], ]; } @@ -403,6 +413,24 @@ public function testDotExceptSupportsEndingWildcard(): void $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 = [ diff --git a/tests/system/Helpers/ArrayHelperTest.php b/tests/system/Helpers/ArrayHelperTest.php index 15e0fa8889d3..8cbf62235835 100644 --- a/tests/system/Helpers/ArrayHelperTest.php +++ b/tests/system/Helpers/ArrayHelperTest.php @@ -480,6 +480,11 @@ public static function provideDotArrayHas(): iterable 'data' => ['user' => ['score' => 0]], 'expected' => true, ], + 'escaped dot in key' => [ + 'index' => 'config.api\.version', + 'data' => ['config' => ['api.version' => 'v1']], + 'expected' => true, + ], ]; } From 9947a7ac570bb3fa5773b48b1f81f1ef07fb1941 Mon Sep 17 00:00:00 2001 From: michalsn Date: Sun, 22 Feb 2026 21:00:27 +0100 Subject: [PATCH 3/5] add docs --- .../source/helpers/array_helper.rst | 71 +++++++++++++++++++ .../source/helpers/array_helper/015.php | 16 +++++ .../source/helpers/array_helper/016.php | 33 +++++++++ .../source/helpers/array_helper/017.php | 27 +++++++ .../source/helpers/array_helper/018.php | 33 +++++++++ .../source/helpers/array_helper/019.php | 33 +++++++++ 6 files changed, 213 insertions(+) create mode 100644 user_guide_src/source/helpers/array_helper/015.php create mode 100644 user_guide_src/source/helpers/array_helper/016.php create mode 100644 user_guide_src/source/helpers/array_helper/017.php create mode 100644 user_guide_src/source/helpers/array_helper/018.php create mode 100644 user_guide_src/source/helpers/array_helper/019.php diff --git a/user_guide_src/source/helpers/array_helper.rst b/user_guide_src/source/helpers/array_helper.rst index 8a9c795d335f..3ddbb96adfc4 100644 --- a/user_guide_src/source/helpers/array_helper.rst +++ b/user_guide_src/source/helpers/array_helper.rst @@ -56,6 +56,77 @@ 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 + + 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 + + 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 + + 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 + + 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 + + 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'], +] +*/ From c6afc73cad4d52f7c87c74c55bcd6bd4a39a0d24 Mon Sep 17 00:00:00 2001 From: michalsn Date: Sun, 22 Feb 2026 21:20:27 +0100 Subject: [PATCH 4/5] add changelog --- user_guide_src/source/changelogs/v4.8.0.rst | 4 ++++ user_guide_src/source/helpers/array_helper.rst | 10 ++++++++++ 2 files changed, 14 insertions(+) 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 3ddbb96adfc4..fd13e0ae6f6f 100644 --- a/user_guide_src/source/helpers/array_helper.rst +++ b/user_guide_src/source/helpers/array_helper.rst @@ -63,6 +63,8 @@ The following functions are available: :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()``. @@ -76,6 +78,8 @@ The following functions are available: :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 ``*``. @@ -90,6 +94,8 @@ The following functions are available: :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.*``. @@ -104,6 +110,8 @@ The following functions are available: :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()``, @@ -119,6 +127,8 @@ The following functions are available: :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()``, From c38a13b95927eb6f5c6dbe4d762d33633a636bcc Mon Sep 17 00:00:00 2001 From: michalsn Date: Sun, 22 Feb 2026 21:32:25 +0100 Subject: [PATCH 5/5] update tests --- tests/system/Helpers/Array/ArrayHelperDotHasTest.php | 8 ++++---- tests/system/Helpers/Array/ArrayHelperDotModifyTest.php | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/system/Helpers/Array/ArrayHelperDotHasTest.php b/tests/system/Helpers/Array/ArrayHelperDotHasTest.php index 47590d6b6426..4b8b4751123a 100644 --- a/tests/system/Helpers/Array/ArrayHelperDotHasTest.php +++ b/tests/system/Helpers/Array/ArrayHelperDotHasTest.php @@ -32,7 +32,7 @@ final class ArrayHelperDotHasTest extends CIUnitTestCase ], ]; - public function testDotKeyExists(): void + public function testDotHas(): void { $this->assertFalse(ArrayHelper::dotHas('', $this->array)); $this->assertTrue(ArrayHelper::dotHas('contacts', $this->array)); @@ -43,7 +43,7 @@ public function testDotKeyExists(): void $this->assertFalse(ArrayHelper::dotHas('contacts.friends.1.name', $this->array)); } - public function testDotKeyExistsWithEndingWildCard(): void + public function testDotHasWithEndingWildCard(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('You must set key right after "*". Invalid index: "contacts.*"'); @@ -51,7 +51,7 @@ public function testDotKeyExistsWithEndingWildCard(): void $this->assertTrue(ArrayHelper::dotHas('contacts.*', $this->array)); } - public function testDotKeyExistsWithDoubleWildCard(): void + public function testDotHasWithDoubleWildCard(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('You must set key right after "*". Invalid index: "contacts.*.*.age"'); @@ -59,7 +59,7 @@ public function testDotKeyExistsWithDoubleWildCard(): void $this->assertTrue(ArrayHelper::dotHas('contacts.*.*.age', $this->array)); } - public function testDotKeyExistsWithWildCard(): void + public function testDotHasWithWildCard(): void { $this->assertTrue(ArrayHelper::dotHas('*.friends', $this->array)); $this->assertTrue(ArrayHelper::dotHas('contacts.friends.*.age', $this->array)); diff --git a/tests/system/Helpers/Array/ArrayHelperDotModifyTest.php b/tests/system/Helpers/Array/ArrayHelperDotModifyTest.php index b5c80bbb6c83..5560703344c9 100644 --- a/tests/system/Helpers/Array/ArrayHelperDotModifyTest.php +++ b/tests/system/Helpers/Array/ArrayHelperDotModifyTest.php @@ -54,8 +54,8 @@ public function testDotSetWithEscapedDotKey(): void /** * @param array $array */ - #[DataProvider('provideDotKeyExists')] - public function testDotKeyExists(string $index, array $array, bool $expected): void + #[DataProvider('provideDotHas')] + public function testDotHas(string $index, array $array, bool $expected): void { $this->assertSame($expected, ArrayHelper::dotHas($index, $array)); } @@ -63,7 +63,7 @@ public function testDotKeyExists(string $index, array $array, bool $expected): v /** * @return iterable, expected: bool}> */ - public static function provideDotKeyExists(): iterable + public static function provideDotHas(): iterable { yield from [ 'null value at leaf' => [