From 3ae27aee2c407be37a64191053e0489fe78d0144 Mon Sep 17 00:00:00 2001 From: syawqy Date: Tue, 2 Jun 2026 11:17:29 +0700 Subject: [PATCH 1/4] feat: support objects in remaining dot array helpers --- system/Helpers/Array/ArrayHelper.php | 105 ++++++++---- system/Helpers/array_helper.php | 16 +- tests/system/Helpers/ArrayHelperTest.php | 158 ++++++++++++++++++ user_guide_src/source/changelogs/v4.8.0.rst | 5 +- .../source/helpers/array_helper.rst | 18 +- 5 files changed, 254 insertions(+), 48 deletions(-) diff --git a/system/Helpers/Array/ArrayHelper.php b/system/Helpers/Array/ArrayHelper.php index c723d6f84214..8213bd1528c7 100644 --- a/system/Helpers/Array/ArrayHelper.php +++ b/system/Helpers/Array/ArrayHelper.php @@ -146,9 +146,9 @@ private static function arraySearchDot(array $indexes, array|object $array) * * If wildcard `*` is used, all items for the key after it must have the key. * - * @param array $array + * @param array|object $array */ - public static function dotHas(string $index, array $array): bool + public static function dotHas(string $index, array|object $array): bool { self::ensureValidWildcardPattern($index); @@ -164,10 +164,10 @@ public static function dotHas(string $index, array $array): bool /** * Recursively check key existence by dot path, including wildcard support. * - * @param array $array - * @param list $indexes + * @param array|object $array + * @param list $indexes */ - private static function hasByDotPath(array $array, array $indexes): bool + private static function hasByDotPath(array|object $array, array $indexes): bool { if ($indexes === []) { return true; @@ -176,8 +176,10 @@ private static function hasByDotPath(array $array, array $indexes): bool $currentIndex = array_shift($indexes); if ($currentIndex === '*') { - foreach ($array as $item) { - if (! is_array($item) || ! self::hasByDotPath($item, $indexes)) { + $iterable = is_object($array) ? self::toIterable($array) : $array; + + foreach ($iterable as $item) { + if ((! is_array($item) && ! is_object($item)) || ! self::hasByDotPath($item, $indexes)) { return false; } } @@ -185,7 +187,7 @@ private static function hasByDotPath(array $array, array $indexes): bool return true; } - if (! array_key_exists($currentIndex, $array)) { + if (! self::valueExists($array, $currentIndex)) { return false; } @@ -193,11 +195,13 @@ private static function hasByDotPath(array $array, array $indexes): bool return true; } - if (! is_array($array[$currentIndex])) { + $value = self::value($array, $currentIndex); + + if (! is_array($value) && ! is_object($value)) { return false; } - return self::hasByDotPath($array[$currentIndex], $indexes); + return self::hasByDotPath($value, $indexes); } /** @@ -247,12 +251,12 @@ public static function dotUnset(array &$array, string $index): bool /** * Gets only the specified keys using dot syntax. * - * @param array $array - * @param list|string $indexes + * @param array|object $array + * @param list|string $indexes * * @return array */ - public static function dotOnly(array $array, array|string $indexes): array + public static function dotOnly(array|object $array, array|string $indexes): array { $indexes = is_string($indexes) ? [$indexes] : $indexes; $result = []; @@ -261,7 +265,7 @@ public static function dotOnly(array $array, array|string $indexes): array self::ensureValidWildcardPattern($index, true); if ($index === '*') { - $result = [...$result, ...$array]; + $result = [...$result, ...(is_object($array) ? self::toIterable($array) : $array)]; continue; } @@ -280,15 +284,15 @@ public static function dotOnly(array $array, array|string $indexes): array /** * Gets all keys except the specified ones using dot syntax. * - * @param array $array - * @param list|string $indexes + * @param array|object $array + * @param list|string $indexes * * @return array */ - public static function dotExcept(array $array, array|string $indexes): array + public static function dotExcept(array|object $array, array|string $indexes): array { $indexes = is_string($indexes) ? [$indexes] : $indexes; - $result = $array; + $result = self::toArrayView($array); foreach ($indexes as $index) { self::ensureValidWildcardPattern($index, true); @@ -466,20 +470,20 @@ public static function sortValuesByNatural(array &$array, $sortByIndex = null): private static function valueExists(array|object $data, string $key): bool { if (is_array($data)) { - return isset($data[$key]); + return array_key_exists($key, $data); } $array = self::entityToArray($data); if ($array !== null) { - return isset($array[$key]); + return array_key_exists($key, $array); } if ($data instanceof ArrayAccess && $data->offsetExists($key)) { return true; } - if (isset(get_object_vars($data)[$key])) { + if (array_key_exists($key, get_object_vars($data))) { return true; } @@ -550,6 +554,26 @@ private static function toIterable(object $data): array return get_object_vars($data); } + /** + * Normalize arrays or objects to an array view safe for dotExcept(). + * + * @param array|object $data + * + * @return array + */ + private static function toArrayView(array|object $data): array + { + $array = is_object($data) ? self::toIterable($data) : $data; + + foreach ($array as $key => $value) { + if (is_array($value) || is_object($value)) { + $array[$key] = self::toArrayView($value); + } + } + + return $array; + } + /** * Throws exception for invalid wildcard patterns. */ @@ -688,14 +712,15 @@ private static function clearByDotPath(array &$array, array $indexes): int } /** - * Projects matching paths from source array into result with preserved structure. + * Projects matching paths from source into result with preserved structure. * - * @param list $indexes - * @param list $prefix - * @param array $result + * @param array|object $source + * @param list $indexes + * @param list $prefix + * @param array $result */ private static function projectByDotPath( - mixed $source, + array|object $source, array $indexes, array &$result, array $prefix = [], @@ -709,21 +734,37 @@ private static function projectByDotPath( $currentIndex = array_shift($indexes); if ($currentIndex === '*') { - if (! is_array($source)) { - return; - } + $iterable = is_object($source) ? self::toIterable($source) : $source; + + foreach ($iterable as $key => $value) { + if (! is_array($value) && ! is_object($value)) { + if ($indexes === []) { + self::setByDotPath($result, [...$prefix, (string) $key], $value); + } + + continue; + } - foreach ($source as $key => $value) { self::projectByDotPath($value, $indexes, $result, [...$prefix, (string) $key]); } return; } - if (! is_array($source) || ! array_key_exists($currentIndex, $source)) { + if (! self::valueExists($source, $currentIndex)) { + return; + } + + $value = self::value($source, $currentIndex); + + if (! is_array($value) && ! is_object($value)) { + if ($indexes === []) { + self::setByDotPath($result, [...$prefix, $currentIndex], $value); + } + return; } - self::projectByDotPath($source[$currentIndex], $indexes, $result, [...$prefix, $currentIndex]); + self::projectByDotPath($value, $indexes, $result, [...$prefix, $currentIndex]); } } diff --git a/system/Helpers/array_helper.php b/system/Helpers/array_helper.php index 66ff9c8b8d81..a752b3ff2e3b 100644 --- a/system/Helpers/array_helper.php +++ b/system/Helpers/array_helper.php @@ -34,9 +34,9 @@ function dot_array_search(string $index, array|object $array) /** * Checks if an array key exists using dot syntax. * - * @param array $array + * @param array|object $array */ - function dot_array_has(string $index, array $array): bool + function dot_array_has(string $index, array|object $array): bool { return ArrayHelper::dotHas($index, $array); } @@ -70,12 +70,12 @@ function dot_array_unset(array &$array, string $index): bool /** * Gets only the specified keys using dot syntax. * - * @param array $array - * @param list|string $indexes + * @param array|object $array + * @param list|string $indexes * * @return array */ - function dot_array_only(array $array, array|string $indexes): array + function dot_array_only(array|object $array, array|string $indexes): array { return ArrayHelper::dotOnly($array, $indexes); } @@ -85,12 +85,12 @@ function dot_array_only(array $array, array|string $indexes): array /** * Gets all keys except the specified ones using dot syntax. * - * @param array $array - * @param list|string $indexes + * @param array|object $array + * @param list|string $indexes * * @return array */ - function dot_array_except(array $array, array|string $indexes): array + function dot_array_except(array|object $array, array|string $indexes): array { return ArrayHelper::dotExcept($array, $indexes); } diff --git a/tests/system/Helpers/ArrayHelperTest.php b/tests/system/Helpers/ArrayHelperTest.php index d331f24a655b..0ba282a78ab0 100644 --- a/tests/system/Helpers/ArrayHelperTest.php +++ b/tests/system/Helpers/ArrayHelperTest.php @@ -177,6 +177,50 @@ public function testDotArrayOnlySupportsEndingWildcard(): void $this->assertSame($expected, dot_array_only($data, 'user.*')); } + public function testDotArrayOnlyWithObjectValues(): void + { + $data = (object) [ + 'user' => (object) [ + 'id' => 123, + 'profile' => (object) [ + 'name' => 'john', + ], + ], + 'meta' => ['request_id' => 'abc'], + ]; + + $expected = [ + 'user' => [ + 'profile' => [ + 'name' => 'john', + ], + ], + ]; + + $this->assertSame($expected, dot_array_only($data, 'user.profile.name')); + } + + public function testDotArrayOnlyWildcardWithEntityRows(): void + { + $a = new SomeEntity(); + $a->foo = 1; + $a->bar = 2; + + $b = new SomeEntity(); + $b->foo = 3; + $b->bar = 4; + + $this->assertSame( + [ + 'rows' => [ + ['foo' => 1], + ['foo' => 3], + ], + ], + dot_array_only(['rows' => [$a, $b]], 'rows.*.foo'), + ); + } + public function testDotArrayExcept(): void { $data = [ @@ -215,6 +259,44 @@ public function testDotArrayExceptSupportsEndingWildcard(): void $this->assertSame($expected, dot_array_except($data, 'user.*')); } + public function testDotArrayExceptWithObjectValues(): void + { + $data = (object) [ + 'user' => (object) [ + 'id' => 123, + 'name' => 'john', + ], + 'meta' => (object) ['request_id' => 'abc'], + ]; + + $expected = [ + 'user' => [ + 'name' => 'john', + ], + 'meta' => ['request_id' => 'abc'], + ]; + + $this->assertSame($expected, dot_array_except($data, 'user.id')); + } + + public function testDotArrayExceptWildcardWithObjectValues(): void + { + $data = (object) [ + 'user' => (object) [ + '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 = [ @@ -458,6 +540,82 @@ public function testArrayDotWildcardWithObjectValues(): void $this->assertSame(['John', 'Maria'], dot_array_search('users.*.name', $data)); } + public function testDotArrayHasWithObjectValues(): void + { + $data = [ + 'user' => (object) [ + 'profile' => (object) [ + 'name' => 'Jane', + ], + ], + ]; + + $this->assertTrue(dot_array_has('user.profile.name', $data)); + $this->assertFalse(dot_array_has('user.profile.email', $data)); + } + + public function testDotArrayHasWithMagicObjectValues(): void + { + $data = [ + 'user' => new class () { + /** + * @var array> + */ + private array $values = [ + 'profile' => [ + 'name' => 'Jane', + ], + ]; + + public function __isset(string $key): bool + { + return array_key_exists($key, $this->values); + } + + public function __get(string $key): mixed + { + return $this->values[$key]; + } + }, + ]; + + $this->assertTrue(dot_array_has('user.profile.name', $data)); + } + + public function testDotArrayHasWithArrayAccessValues(): void + { + $data = [ + 'user' => new ArrayObject([ + 'profile' => [ + 'name' => 'Jane', + ], + ]), + ]; + + $this->assertTrue(dot_array_has('user.profile.name', $data)); + } + + public function testDotArrayHasWithEntityValues(): void + { + $entity = new SomeEntity(); + $entity->foo = 'value'; + + $this->assertTrue(dot_array_has('user.foo', ['user' => $entity])); + $this->assertFalse(dot_array_has('user._options', ['user' => $entity])); + } + + public function testDotArrayHasWildcardWithEntityValues(): void + { + $a = new SomeEntity(); + $a->foo = 1; + + $b = new SomeEntity(); + $b->foo = 2; + + $this->assertTrue(dot_array_has('rows.*.foo', ['rows' => [$a, $b]])); + $this->assertFalse(dot_array_has('rows.*._cast', ['rows' => [$a, $b]])); + } + /** * @param int|string $key * @param array|string|null $expected diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index f1c369184f3b..523a3f279c8d 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -271,8 +271,9 @@ Helpers and 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()`. - :doc:`Array Helper ` dot-path read operations now support - object rows (including ``Entity``) in :php:func:`dot_array_search()` and - :php:func:`array_group_by()`. + object rows (including ``Entity``) in :php:func:`dot_array_search()`, + :php:func:`dot_array_has()`, :php:func:`dot_array_only()`, + :php:func:`dot_array_except()`, and :php:func:`array_group_by()`. HTTP ==== diff --git a/user_guide_src/source/helpers/array_helper.rst b/user_guide_src/source/helpers/array_helper.rst index fc48cd4c6eff..739abdd79395 100644 --- a/user_guide_src/source/helpers/array_helper.rst +++ b/user_guide_src/source/helpers/array_helper.rst @@ -58,10 +58,10 @@ The following functions are available: .. note:: Prior to v4.8.0, only arrays were supported. Support for objects was added in v4.8.0. -.. php:function:: dot_array_has(string $search, array $values): bool +.. php:function:: dot_array_has(string $search, array|object $values): bool :param string $search: The dot-notation string describing how to search the array - :param array $values: The array to check + :param array|object $values: The array or object to check :returns: ``true`` if the key exists, otherwise ``false`` :rtype: bool @@ -70,6 +70,8 @@ The following functions are available: Checks if an array key exists using dot syntax. This method supports wildcard ``*`` in the same way as ``dot_array_search()``. + .. note:: Prior to v4.8.0, only arrays were supported. Support for objects was added in v4.8.0. + .. literalinclude:: array_helper/015.php :lines: 2- @@ -105,9 +107,9 @@ The following functions are available: .. literalinclude:: array_helper/017.php :lines: 2- -.. php:function:: dot_array_only(array $array, array|string $indexes): array +.. php:function:: dot_array_only(array|object $array, array|string $indexes): array - :param array $array: The source array + :param array|object $array: The source array or object :param array|string $indexes: One key or a list of keys using dot notation :returns: Nested array containing only the requested keys :rtype: array @@ -119,12 +121,14 @@ The following functions are available: Wildcard ``*`` is supported. Unlike ``dot_array_set()`` and ``dot_array_unset()``, this method also allows wildcard at the end (for example ``user.*``). + .. note:: Prior to v4.8.0, only arrays were supported. Support for objects was added in v4.8.0. + .. literalinclude:: array_helper/018.php :lines: 2- -.. php:function:: dot_array_except(array $array, array|string $indexes): array +.. php:function:: dot_array_except(array|object $array, array|string $indexes): array - :param array $array: The source array + :param array|object $array: The source array or object :param array|string $indexes: One key or a list of keys using dot notation :returns: Nested array with the specified keys removed :rtype: array @@ -136,6 +140,8 @@ The following functions are available: Wildcard ``*`` is supported. Unlike ``dot_array_set()`` and ``dot_array_unset()``, this method also allows wildcard at the end (for example ``user.*``). + .. note:: Prior to v4.8.0, only arrays were supported. Support for objects was added in v4.8.0. + .. literalinclude:: array_helper/019.php :lines: 2- From 45b27aa981638c1e9705d29b1e0033d41a204ea5 Mon Sep 17 00:00:00 2001 From: michalsn Date: Tue, 9 Jun 2026 22:34:52 +0200 Subject: [PATCH 2/4] feat: support objects in remaining dot array helpers --- system/Helpers/Array/ArrayHelper.php | 274 ++++++++++++++++------- tests/system/Helpers/ArrayHelperTest.php | 93 +++++++- 2 files changed, 282 insertions(+), 85 deletions(-) diff --git a/system/Helpers/Array/ArrayHelper.php b/system/Helpers/Array/ArrayHelper.php index 8213bd1528c7..86e0b4c28ebd 100644 --- a/system/Helpers/Array/ArrayHelper.php +++ b/system/Helpers/Array/ArrayHelper.php @@ -16,6 +16,7 @@ use ArrayAccess; use CodeIgniter\Entity\Entity; use CodeIgniter\Exceptions\InvalidArgumentException; +use stdClass; use Traversable; /** @@ -97,17 +98,12 @@ private static function arraySearchDot(array $indexes, array|object $array) // Grab the current index $currentIndex = array_shift($indexes); - if (! self::valueExists($array, $currentIndex) && $currentIndex !== '*') { - return null; - } - // Handle Wildcard (*) if ($currentIndex === '*') { - $answer = []; - $iterable = is_object($array) ? self::toIterable($array) : $array; + $answer = []; - foreach ($iterable as $value) { - if (! is_array($value) && ! is_object($value)) { + foreach (self::entries($array) as $value) { + if (! self::isNavigable($value)) { return null; } @@ -124,14 +120,18 @@ private static function arraySearchDot(array $indexes, array|object $array) return null; } + [$found, $value] = self::resolve($array, $currentIndex); + + if (! $found) { + return null; + } + // If this is the last index, make sure to return it now, // and not try to recurse through things. if ($indexes === []) { - return self::value($array, $currentIndex); + return $value; } - $value = self::value($array, $currentIndex); - // Do we need to recursively search this value? if ((is_array($value) && $value !== []) || is_object($value)) { return self::arraySearchDot($indexes, $value); @@ -176,10 +176,8 @@ private static function hasByDotPath(array|object $array, array $indexes): bool $currentIndex = array_shift($indexes); if ($currentIndex === '*') { - $iterable = is_object($array) ? self::toIterable($array) : $array; - - foreach ($iterable as $item) { - if ((! is_array($item) && ! is_object($item)) || ! self::hasByDotPath($item, $indexes)) { + foreach (self::entries($array) as $item) { + if (! self::isNavigable($item) || ! self::hasByDotPath($item, $indexes)) { return false; } } @@ -187,7 +185,9 @@ private static function hasByDotPath(array|object $array, array $indexes): bool return true; } - if (! self::valueExists($array, $currentIndex)) { + [$found, $value] = self::resolve($array, $currentIndex); + + if (! $found) { return false; } @@ -195,9 +195,7 @@ private static function hasByDotPath(array|object $array, array $indexes): bool return true; } - $value = self::value($array, $currentIndex); - - if (! is_array($value) && ! is_object($value)) { + if (! self::isNavigable($value)) { return false; } @@ -292,7 +290,10 @@ public static function dotOnly(array|object $array, array|string $indexes): arra public static function dotExcept(array|object $array, array|string $indexes): array { $indexes = is_string($indexes) ? [$indexes] : $indexes; - $result = self::toArrayView($array); + + // Open only the root into an array view; nested values (including + // objects) are preserved until a path actually descends into them. + $result = self::entries($array); foreach ($indexes as $index) { self::ensureValidWildcardPattern($index, true); @@ -303,17 +304,18 @@ public static function dotExcept(array|object $array, array|string $indexes): ar continue; } + $segments = self::convertToArray($index); + if ($segments === []) { + continue; + } + if (str_ends_with($index, '*')) { - $segments = self::convertToArray($index); - self::clearByDotPath($result, $segments); + self::excludeChildrenByDotPath($result, $segments); continue; } - $segments = self::convertToArray($index); - if ($segments !== []) { - self::unsetByDotPath($result, $segments); - } + self::excludeByDotPath($result, $segments); } return $result; @@ -465,57 +467,70 @@ public static function sortValuesByNatural(array &$array, $sortByIndex = null): } /** - * @param array|object $data + * Resolve a key against an array or object node, walking the access chain + * (Entity, ArrayAccess, public properties, magic `__isset`/`__get`) once. + * + * @param array|object $node + * + * @return array{bool, mixed} The pair [found, value]. */ - private static function valueExists(array|object $data, string $key): bool + private static function resolve(array|object $node, string $key): array { - if (is_array($data)) { - return array_key_exists($key, $data); + if (is_array($node)) { + return array_key_exists($key, $node) ? [true, $node[$key]] : [false, null]; } - $array = self::entityToArray($data); + $array = self::entityToArray($node); if ($array !== null) { - return array_key_exists($key, $array); + return array_key_exists($key, $array) ? [true, $array[$key]] : [false, null]; } - if ($data instanceof ArrayAccess && $data->offsetExists($key)) { - return true; + if ($node instanceof ArrayAccess && $node->offsetExists($key)) { + return [true, $node->offsetGet($key)]; } - if (array_key_exists($key, get_object_vars($data))) { - return true; + $properties = get_object_vars($node); + + if (array_key_exists($key, $properties)) { + return [true, $properties[$key]]; } - return isset($data->{$key}); + return isset($node->{$key}) ? [true, $node->{$key}] : [false, null]; } /** - * @param array|object $data + * Whether keys can be resolved from this value, i.e. it is an array or an + * object that exposes a key surface: an expandable container, an + * `ArrayAccess`, or one relying on magic `__get`. Pure value-objects + * (e.g. `DateTimeImmutable`) are not navigable. + * + * Direct key lookup can support more object types than wildcard traversal: + * `ArrayAccess` and magic-only objects can resolve `user.id`, but cannot be + * enumerated for `user.*` unless they are also expandable. */ - private static function value(array|object $data, string $key): mixed + private static function isNavigable(mixed $value): bool { - if (is_array($data)) { - return $data[$key]; - } - - $array = self::entityToArray($data); - - if ($array !== null) { - return $array[$key]; - } - - if ($data instanceof ArrayAccess && $data->offsetExists($key)) { - return $data->offsetGet($key); + if (is_array($value)) { + return true; } - $properties = get_object_vars($data); - - if (array_key_exists($key, $properties)) { - return $properties[$key]; - } + return is_object($value) + && (self::isExpandable($value) + || $value instanceof ArrayAccess + || method_exists($value, '__get')); + } - return $data->{$key}; + /** + * Entries of an array or object node for wildcard traversal. + * + * @param array|object $node + * + * @return array + */ + private static function entries(array|object $node): array + { + return is_object($node) ? self::toIterable($node) : $node; } /** @@ -535,7 +550,8 @@ private static function entityToArray(object $data): ?array * * Entities are converted via toArray() so internal properties like * `_options` or `_cast` are not exposed. Other Traversable objects are - * materialized; plain objects fall back to their public properties. + * converted to an array with their keys preserved; plain objects fall back + * to their public properties. * * @return array */ @@ -548,30 +564,50 @@ private static function toIterable(object $data): array } if ($data instanceof Traversable) { - return iterator_to_array($data, false); + return iterator_to_array($data); } return get_object_vars($data); } /** - * Normalize arrays or objects to an array view safe for dotExcept(). + * Whether an object should be expanded into an array when building output. * - * @param array|object $data + * Only enumerable containers are expanded: entities, `stdClass`, other + * `Traversable` objects, and plain objects exposing public properties. + * Opaque objects with no enumerable key surface (value-objects such as + * `DateTimeImmutable`, magic-only or pure `ArrayAccess` objects) are + * preserved as-is, since they cannot be faithfully rebuilt as an array. + */ + private static function isExpandable(object $value): bool + { + return $value instanceof Entity + || $value instanceof stdClass + || $value instanceof Traversable + || get_object_vars($value) !== []; + } + + /** + * Ensure a value can be descended into for a partial exclusion/projection. * - * @return array + * Arrays pass through; expandable objects are converted to an array view + * in place (this is the only point where output structure is fabricated). + * Anything else (scalars, value-objects, magic-only or pure `ArrayAccess` + * objects) is left untouched and reported as non-descendable. */ - private static function toArrayView(array|object $data): array + private static function expandForDescent(mixed &$value): bool { - $array = is_object($data) ? self::toIterable($data) : $data; + if (is_array($value)) { + return true; + } - foreach ($array as $key => $value) { - if (is_array($value) || is_object($value)) { - $array[$key] = self::toArrayView($value); - } + if (is_object($value) && self::isExpandable($value)) { + $value = self::entries($value); + + return true; } - return $array; + return false; } /** @@ -711,6 +747,90 @@ private static function clearByDotPath(array &$array, array $indexes): int return self::clearByDotPath($array[$currentIndex], $indexes); } + /** + * Removes a value by dot path for dotExcept(). Objects are expanded to an + * array view only when the path descends into them, so untouched branches + * keep their original values (including objects). + * + * @param array $array + * @param list $indexes + */ + private static function excludeByDotPath(array &$array, array $indexes): int + { + if ($indexes === []) { + return 0; + } + + $currentIndex = array_shift($indexes); + + if ($currentIndex === '*') { + $removed = 0; + + foreach ($array as &$item) { + if (self::expandForDescent($item)) { + $removed += self::excludeByDotPath($item, $indexes); + } + } + unset($item); + + return $removed; + } + + if ($indexes === []) { + if (! array_key_exists($currentIndex, $array)) { + return 0; + } + + unset($array[$currentIndex]); + + return 1; + } + + if (! array_key_exists($currentIndex, $array) || ! self::expandForDescent($array[$currentIndex])) { + return 0; + } + + return self::excludeByDotPath($array[$currentIndex], $indexes); + } + + /** + * Clears all children under the specified path for dotExcept(), expanding + * objects to an array view only along the descended path. + * + * @param array $array + * @param list $indexes + */ + private static function excludeChildrenByDotPath(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 (self::expandForDescent($item)) { + $cleared += self::excludeChildrenByDotPath($item, $indexes); + } + } + unset($item); + + return $cleared; + } + + if (! array_key_exists($currentIndex, $array) || ! self::expandForDescent($array[$currentIndex])) { + return 0; + } + + return self::excludeChildrenByDotPath($array[$currentIndex], $indexes); + } + /** * Projects matching paths from source into result with preserved structure. * @@ -726,6 +846,8 @@ private static function projectByDotPath( array $prefix = [], ): void { if ($indexes === []) { + // The whole node was selected: preserve it as-is. Output structure + // is only fabricated for the projection skeleton above this leaf. self::setByDotPath($result, $prefix, $source); return; @@ -734,10 +856,8 @@ private static function projectByDotPath( $currentIndex = array_shift($indexes); if ($currentIndex === '*') { - $iterable = is_object($source) ? self::toIterable($source) : $source; - - foreach ($iterable as $key => $value) { - if (! is_array($value) && ! is_object($value)) { + foreach (self::entries($source) as $key => $value) { + if (! self::isNavigable($value)) { if ($indexes === []) { self::setByDotPath($result, [...$prefix, (string) $key], $value); } @@ -751,13 +871,13 @@ private static function projectByDotPath( return; } - if (! self::valueExists($source, $currentIndex)) { + [$found, $value] = self::resolve($source, $currentIndex); + + if (! $found) { return; } - $value = self::value($source, $currentIndex); - - if (! is_array($value) && ! is_object($value)) { + if (! self::isNavigable($value)) { if ($indexes === []) { self::setByDotPath($result, [...$prefix, $currentIndex], $value); } diff --git a/tests/system/Helpers/ArrayHelperTest.php b/tests/system/Helpers/ArrayHelperTest.php index 0ba282a78ab0..0c2e0ffccae4 100644 --- a/tests/system/Helpers/ArrayHelperTest.php +++ b/tests/system/Helpers/ArrayHelperTest.php @@ -15,6 +15,7 @@ use ArrayObject; use CodeIgniter\Exceptions\InvalidArgumentException; +use DateTimeImmutable; use CodeIgniter\Test\CIUnitTestCase; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; @@ -221,6 +222,32 @@ public function testDotArrayOnlyWildcardWithEntityRows(): void ); } + public function testDotArrayOnlyPreservesWholeSelectedObject(): void + { + $user = (object) ['id' => 1, 'name' => 'Ada']; + + // Selecting the object as a whole returns it untouched. + $this->assertSame(['user' => $user], dot_array_only(['user' => $user], 'user')); + } + + public function testDotArrayOnlyProjectsPartialObjectAsArray(): void + { + $created = new DateTimeImmutable(); + + $data = [ + 'user' => (object) [ + 'id' => 123, + 'created' => $created, + ], + ]; + + // A partial projection must fabricate array structure for "user"... + $this->assertSame(['user' => ['id' => 123]], dot_array_only($data, 'user.id')); + + // ...while the value-object leaf is preserved as-is. + $this->assertSame(['user' => ['created' => $created]], dot_array_only($data, 'user.created')); + } + public function testDotArrayExcept(): void { $data = [ @@ -261,22 +288,21 @@ public function testDotArrayExceptSupportsEndingWildcard(): void public function testDotArrayExceptWithObjectValues(): void { + $meta = (object) ['request_id' => 'abc']; $data = (object) [ 'user' => (object) [ 'id' => 123, 'name' => 'john', ], - 'meta' => (object) ['request_id' => 'abc'], + 'meta' => $meta, ]; - $expected = [ - 'user' => [ - 'name' => 'john', - ], - 'meta' => ['request_id' => 'abc'], - ]; + $result = dot_array_except($data, 'user.id'); - $this->assertSame($expected, dot_array_except($data, 'user.id')); + // "user" is partially excluded, so it is rebuilt as an array... + $this->assertSame(['name' => 'john'], $result['user']); + // ...but the untouched "meta" object is preserved as-is. + $this->assertSame($meta, $result['meta']); } public function testDotArrayExceptWildcardWithObjectValues(): void @@ -297,6 +323,57 @@ public function testDotArrayExceptWildcardWithObjectValues(): void $this->assertSame($expected, dot_array_except($data, 'user.*')); } + public function testDotArrayExceptPreservesUntouchedObject(): void + { + $user = (object) ['id' => 1, 'name' => 'Ada']; + + // The path does not touch "user", so the object is returned untouched. + $this->assertSame(['user' => $user], dot_array_except(['user' => $user], 'other')); + } + + public function testDotArrayExceptPreservesValueObjects(): void + { + $created = new DateTimeImmutable(); + + $data = [ + 'user' => ['id' => 1], + 'created' => $created, + ]; + + $result = dot_array_except($data, 'user.id'); + + $this->assertSame([], $result['user']); + // Untouched value-objects must survive instead of collapsing to []. + $this->assertSame($created, $result['created']); + } + + public function testDotArrayExceptPreservesTraversableKeys(): void + { + $data = new ArrayObject([ + 'user' => ['id' => 1, 'name' => 'Ada'], + 'meta' => 'm', + ]); + + $expected = [ + 'user' => ['name' => 'Ada'], + 'meta' => 'm', + ]; + + $this->assertSame($expected, dot_array_except($data, 'user.id')); + } + + public function testDotArrayOnlyAndExceptAgreeOnPublicPropertyObjects(): void + { + $user = new class () { + public int $id = 1; + public string $name = 'Ada'; + }; + + // Both helpers treat a public-property object as a container. + $this->assertSame(['user' => ['id' => 1]], dot_array_only(['user' => $user], 'user.id')); + $this->assertSame(['user' => ['name' => 'Ada']], dot_array_except(['user' => $user], 'user.id')); + } + public function testArrayDotTooManyLevels(): void { $data = [ From 77541c9290b8f75f47f10284fd59437f5f707bb7 Mon Sep 17 00:00:00 2001 From: michalsn Date: Tue, 9 Jun 2026 22:35:57 +0200 Subject: [PATCH 3/4] add docs --- user_guide_src/source/changelogs/v4.8.0.rst | 3 ++ .../source/helpers/array_helper.rst | 37 ++++++++++++++----- .../source/helpers/array_helper/020.php | 11 ++++++ .../source/helpers/array_helper/021.php | 11 ++++++ 4 files changed, 52 insertions(+), 10 deletions(-) create mode 100644 user_guide_src/source/helpers/array_helper/020.php create mode 100644 user_guide_src/source/helpers/array_helper/021.php diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index 523a3f279c8d..bbe04725624c 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -274,6 +274,9 @@ Helpers and Functions object rows (including ``Entity``) in :php:func:`dot_array_search()`, :php:func:`dot_array_has()`, :php:func:`dot_array_only()`, :php:func:`dot_array_except()`, and :php:func:`array_group_by()`. + :php:func:`dot_array_only()` and :php:func:`dot_array_except()` still return arrays. If the source itself is an object, + it is read as an array-like value. Object values inside the source are kept unchanged when selected as a whole or left untouched. + Partial object paths are returned as arrays. HTTP ==== diff --git a/user_guide_src/source/helpers/array_helper.rst b/user_guide_src/source/helpers/array_helper.rst index 739abdd79395..8d859de077a1 100644 --- a/user_guide_src/source/helpers/array_helper.rst +++ b/user_guide_src/source/helpers/array_helper.rst @@ -22,6 +22,17 @@ Available Functions The following functions are available: +.. note:: Since v4.8.0, the dot-path helpers can read values from arrays or + objects, including ``Entity`` objects. This applies to + :php:func:`dot_array_search()`, :php:func:`dot_array_has()`, + :php:func:`dot_array_only()`, :php:func:`dot_array_except()`, and + :php:func:`array_group_by()`. + + :php:func:`dot_array_set()` and :php:func:`dot_array_unset()` still modify + arrays only. :php:func:`dot_array_only()` and + :php:func:`dot_array_except()` always return arrays. See their descriptions + for how object values are handled. + .. php:function:: dot_array_search(string $search, array|object $values) :param string $search: The dot-notation string describing how to search the array @@ -56,8 +67,6 @@ 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``. -.. note:: Prior to v4.8.0, only arrays were supported. Support for objects was added in v4.8.0. - .. php:function:: dot_array_has(string $search, array|object $values): bool :param string $search: The dot-notation string describing how to search the array @@ -70,8 +79,6 @@ The following functions are available: Checks if an array key exists using dot syntax. This method supports wildcard ``*`` in the same way as ``dot_array_search()``. - .. note:: Prior to v4.8.0, only arrays were supported. Support for objects was added in v4.8.0. - .. literalinclude:: array_helper/015.php :lines: 2- @@ -121,7 +128,12 @@ The following functions are available: Wildcard ``*`` is supported. Unlike ``dot_array_set()`` and ``dot_array_unset()``, this method also allows wildcard at the end (for example ``user.*``). - .. note:: Prior to v4.8.0, only arrays were supported. Support for objects was added in v4.8.0. + The result is always an array. If a selected value is an object, that object + is kept as the value. If you select a value inside an object, the returned + path is built with arrays: + + .. literalinclude:: array_helper/020.php + :lines: 2- .. literalinclude:: array_helper/018.php :lines: 2- @@ -140,7 +152,12 @@ The following functions are available: Wildcard ``*`` is supported. Unlike ``dot_array_set()`` and ``dot_array_unset()``, this method also allows wildcard at the end (for example ``user.*``). - .. note:: Prior to v4.8.0, only arrays were supported. Support for objects was added in v4.8.0. + The result is always an array. Object values that are not changed are kept + as they are. If a key is removed from inside an object, that part of the + result is returned as an array: + + .. literalinclude:: array_helper/021.php + :lines: 2- .. literalinclude:: array_helper/019.php :lines: 2- @@ -152,7 +169,8 @@ The following functions are available: :returns: The value found within the array, or null :rtype: mixed - Returns the value of an element with a key value in an array of uncertain depth + Returns the value of an element with a key value in an array of uncertain depth. + Only nested arrays are searched; object values are not traversed. .. php:function:: array_sort_by_multiple_keys(array &$array, array $sortColumns) @@ -195,7 +213,8 @@ The following functions are available: :returns: The flattened array This function flattens a multidimensional array to a single key-value array by using dots - as separators for the keys. + as separators for the keys. The source may be any ``iterable``. Only nested arrays are + flattened; object values are kept as-is, as leaf values. .. literalinclude:: array_helper/009.php :lines: 2- @@ -224,8 +243,6 @@ The following functions are available: The depth of returned array equals the number of indexes passed as parameter. Data rows may be arrays or objects, and dot syntax can read nested array keys or object properties. - .. note:: Prior to v4.8.0, only arrays were supported. Support for objects was added in v4.8.0. - The example shows some data (i.e. loaded from an API) with nested arrays. .. literalinclude:: array_helper/012.php diff --git a/user_guide_src/source/helpers/array_helper/020.php b/user_guide_src/source/helpers/array_helper/020.php new file mode 100644 index 000000000000..49e332fcf798 --- /dev/null +++ b/user_guide_src/source/helpers/array_helper/020.php @@ -0,0 +1,11 @@ + 123, 'name' => 'John']; + +// Selecting the object itself keeps the object as the value. +$whole = dot_array_only(['user' => $user], 'user'); +// ['user' => $user] + +// Selecting a value inside the object builds the path with arrays. +$partial = dot_array_only(['user' => $user], 'user.id'); +// ['user' => ['id' => 123]] diff --git a/user_guide_src/source/helpers/array_helper/021.php b/user_guide_src/source/helpers/array_helper/021.php new file mode 100644 index 000000000000..ff73943962cf --- /dev/null +++ b/user_guide_src/source/helpers/array_helper/021.php @@ -0,0 +1,11 @@ + 123, 'name' => 'John']; + +// An untouched object is kept as it is. +$untouched = dot_array_except(['user' => $user], 'meta.id'); +// ['user' => $user] + +// Removing a key from inside an object returns that part as an array. +$partial = dot_array_except(['user' => $user], 'user.id'); +// ['user' => ['name' => 'John']] From ea97018e912c80e77b12a06c1629b2ab9c3a830f Mon Sep 17 00:00:00 2001 From: michalsn Date: Tue, 9 Jun 2026 22:41:28 +0200 Subject: [PATCH 4/4] cs fix --- tests/system/Helpers/ArrayHelperTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/system/Helpers/ArrayHelperTest.php b/tests/system/Helpers/ArrayHelperTest.php index 0c2e0ffccae4..826a376aa081 100644 --- a/tests/system/Helpers/ArrayHelperTest.php +++ b/tests/system/Helpers/ArrayHelperTest.php @@ -15,8 +15,8 @@ use ArrayObject; use CodeIgniter\Exceptions\InvalidArgumentException; -use DateTimeImmutable; use CodeIgniter\Test\CIUnitTestCase; +use DateTimeImmutable; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use stdClass;