• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

codeigniter4 / CodeIgniter4 / 26261250124

22 May 2026 12:30AM UTC coverage: 88.441%. First build
26261250124

Pull #10226

github

web-flow
Merge 178926b47 into 1a3987fdb
Pull Request #10226: feat: support object values in array dot helpers

42 of 46 new or added lines in 1 file covered. (91.3%)

24155 of 27312 relevant lines covered (88.44%)

220.21 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

91.44
/system/Helpers/Array/ArrayHelper.php
1
<?php
2

3
declare(strict_types=1);
4

5
/**
6
 * This file is part of CodeIgniter 4 framework.
7
 *
8
 * (c) CodeIgniter Foundation <admin@codeigniter.com>
9
 *
10
 * For the full copyright and license information, please view
11
 * the LICENSE file that was distributed with this source code.
12
 */
13

14
namespace CodeIgniter\Helpers\Array;
15

16
use CodeIgniter\Exceptions\InvalidArgumentException;
17

18
/**
19
 * @internal This is internal implementation for the framework.
20
 *
21
 * If there are any methods that should be provided, make them
22
 * public APIs via helper functions.
23
 *
24
 * @see \CodeIgniter\Helpers\Array\ArrayHelperDotHasTest
25
 * @see \CodeIgniter\Helpers\Array\ArrayHelperDotModifyTest
26
 * @see \CodeIgniter\Helpers\Array\ArrayHelperRecursiveDiffTest
27
 * @see \CodeIgniter\Helpers\Array\ArrayHelperSortValuesByNaturalTest
28
 */
29
final class ArrayHelper
30
{
31
    /**
32
     * Searches an array through dot syntax. Supports wildcard searches,
33
     * like `foo.*.bar`.
34
     *
35
     * @used-by dot_array_search()
36
     *
37
     * @param string                          $index The index as dot array syntax.
38
     * @param array<array-key, mixed>|object $array
39
     *
40
     * @return array<array-key, mixed>|bool|int|object|string|null
41
     */
42
    public static function dotSearch(string $index, array|object $array)
43
    {
44
        return self::arraySearchDot(self::convertToArray($index), $array);
1,705✔
45
    }
46

47
    /**
48
     * @param string $index The index as dot array syntax.
49
     *
50
     * @return list<string> The index as an array.
51
     */
52
    private static function convertToArray(string $index): array
53
    {
54
        $trimmed = rtrim($index, '* ');
1,802✔
55

56
        if ($trimmed === '') {
1,802✔
57
            return [];
10✔
58
        }
59

60
        // Fast path: no escaped dots, skip the regex entirely.
61
        if (! str_contains($trimmed, '\\.')) {
1,793✔
62
            return array_values(array_filter(
1,785✔
63
                explode('.', $trimmed),
1,785✔
64
                static fn ($s): bool => $s !== '',
1,785✔
65
            ));
1,785✔
66
        }
67

68
        // See https://regex101.com/r/44Ipql/1
69
        $segments = preg_split('/(?<!\\\\)\./', $trimmed, 0, PREG_SPLIT_NO_EMPTY);
8✔
70

71
        return array_map(
8✔
72
            static fn ($key): string => str_replace('\.', '.', $key),
8✔
73
            $segments,
8✔
74
        );
8✔
75
    }
76

77
    /**
78
     * Recursively search the array with wildcards.
79
     *
80
     * @used-by dotSearch()
81
     *
82
     * @param list<string>                   $indexes
83
     * @param array<array-key, mixed>|object $array
84
     *
85
     * @return array<array-key, mixed>|bool|float|int|object|string|null
86
     */
87
    private static function arraySearchDot(array $indexes, array|object $array)
88
    {
89
        // If index is empty, returns null.
90
        if ($indexes === []) {
1,705✔
91
            return null;
9✔
92
        }
93

94
        // Grab the current index
95
        $currentIndex = array_shift($indexes);
1,696✔
96

97
        if (! self::valueExists($array, $currentIndex) && $currentIndex !== '*') {
1,696✔
98
            return null;
273✔
99
        }
100

101
        // Handle Wildcard (*)
102
        if ($currentIndex === '*') {
1,467✔
103
            $answer = [];
10✔
104

105
            foreach ($array as $value) {
10✔
106
                if (! is_array($value) && ! is_object($value)) {
10✔
107
                    return null;
1✔
108
                }
109

110
                $answer[] = self::arraySearchDot($indexes, $value);
9✔
111
            }
112

113
            $answer = array_filter($answer, static fn ($value): bool => $value !== null);
9✔
114

115
            if ($answer !== []) {
9✔
116
                // If array only has one element, we return that element for BC.
117
                return count($answer) === 1 ? current($answer) : $answer;
8✔
118
            }
119

120
            return null;
1✔
121
        }
122

123
        // If this is the last index, make sure to return it now,
124
        // and not try to recurse through things.
125
        if ($indexes === []) {
1,467✔
126
            return self::value($array, $currentIndex);
1,454✔
127
        }
128

129
        $value = self::value($array, $currentIndex);
78✔
130

131
        // Do we need to recursively search this value?
132
        if ((is_array($value) && $value !== []) || is_object($value)) {
78✔
133
            return self::arraySearchDot($indexes, $value);
75✔
134
        }
135

136
        // Otherwise, not found.
137
        return null;
6✔
138
    }
139

140
    /**
141
     * array_key_exists() with dot array syntax.
142
     *
143
     * If wildcard `*` is used, all items for the key after it must have the key.
144
     *
145
     * @param array<array-key, mixed>|object $array
146
     */
147
    public static function dotHas(string $index, array|object $array): bool
148
    {
149
        self::ensureValidWildcardPattern($index);
113✔
150

151
        $indexes = self::convertToArray($index);
110✔
152

153
        if ($indexes === []) {
110✔
154
            return false;
1✔
155
        }
156

157
        return self::hasByDotPath($array, $indexes);
110✔
158
    }
159

160
    /**
161
     * Recursively check key existence by dot path, including wildcard support.
162
     *
163
     * @param array<array-key, mixed>|object $array
164
     * @param list<string>                   $indexes
165
     */
166
    private static function hasByDotPath(array|object $array, array $indexes): bool
167
    {
168
        if ($indexes === []) {
110✔
169
            return true;
×
170
        }
171

172
        $currentIndex = array_shift($indexes);
110✔
173

174
        if ($currentIndex === '*') {
110✔
175
            foreach ($array as $item) {
16✔
176
                if ((! is_array($item) && ! is_object($item)) || ! self::hasByDotPath($item, $indexes)) {
16✔
177
                    return false;
10✔
178
                }
179
            }
180

181
            return true;
9✔
182
        }
183

184
        if (! self::keyExists($array, $currentIndex)) {
110✔
185
            return false;
49✔
186
        }
187

188
        if ($indexes === []) {
93✔
189
            return true;
86✔
190
        }
191

192
        $value = self::value($array, $currentIndex);
59✔
193

194
        if (! is_array($value) && ! is_object($value)) {
59✔
NEW
195
            return false;
×
196
        }
197

198
        return self::hasByDotPath($value, $indexes);
59✔
199
    }
200

201
    /**
202
     * Sets a value by dot array syntax.
203
     *
204
     * @param array<array-key, mixed> $array
205
     */
206
    public static function dotSet(array &$array, string $index, mixed $value): void
207
    {
208
        self::ensureValidWildcardPattern($index);
44✔
209

210
        $indexes = self::convertToArray($index);
42✔
211

212
        if ($indexes === []) {
42✔
213
            return;
×
214
        }
215

216
        self::setByDotPath($array, $indexes, $value);
42✔
217
    }
218

219
    /**
220
     * Removes a value by dot array syntax.
221
     *
222
     * @param array<array-key, mixed> $array
223
     */
224
    public static function dotUnset(array &$array, string $index): bool
225
    {
226
        self::ensureValidWildcardPattern($index, true);
18✔
227

228
        if ($index === '*') {
16✔
229
            return self::clearByDotPath($array, []) > 0;
1✔
230
        }
231

232
        $indexes = self::convertToArray($index);
15✔
233

234
        if ($indexes === []) {
15✔
235
            return false;
×
236
        }
237

238
        if (str_ends_with($index, '*')) {
15✔
239
            return self::clearByDotPath($array, $indexes) > 0;
2✔
240
        }
241

242
        return self::unsetByDotPath($array, $indexes) > 0;
13✔
243
    }
244

245
    /**
246
     * Gets only the specified keys using dot syntax.
247
     *
248
     * @param array<array-key, mixed> $array
249
     * @param list<string>|string     $indexes
250
     *
251
     * @return array<array-key, mixed>
252
     */
253
    public static function dotOnly(array $array, array|string $indexes): array
254
    {
255
        $indexes = is_string($indexes) ? [$indexes] : $indexes;
18✔
256
        $result  = [];
18✔
257

258
        foreach ($indexes as $index) {
18✔
259
            self::ensureValidWildcardPattern($index, true);
18✔
260

261
            if ($index === '*') {
18✔
262
                $result = [...$result, ...$array];
1✔
263

264
                continue;
1✔
265
            }
266

267
            $segments = self::convertToArray($index);
17✔
268
            if ($segments === []) {
17✔
269
                continue;
×
270
            }
271

272
            self::projectByDotPath($array, $segments, $result);
17✔
273
        }
274

275
        return $result;
18✔
276
    }
277

278
    /**
279
     * Gets all keys except the specified ones using dot syntax.
280
     *
281
     * @param array<array-key, mixed> $array
282
     * @param list<string>|string     $indexes
283
     *
284
     * @return array<array-key, mixed>
285
     */
286
    public static function dotExcept(array $array, array|string $indexes): array
287
    {
288
        $indexes = is_string($indexes) ? [$indexes] : $indexes;
16✔
289
        $result  = $array;
16✔
290

291
        foreach ($indexes as $index) {
16✔
292
            self::ensureValidWildcardPattern($index, true);
16✔
293

294
            if ($index === '*') {
16✔
295
                $result = [];
1✔
296

297
                continue;
1✔
298
            }
299

300
            if (str_ends_with($index, '*')) {
15✔
301
                $segments = self::convertToArray($index);
2✔
302
                self::clearByDotPath($result, $segments);
2✔
303

304
                continue;
2✔
305
            }
306

307
            $segments = self::convertToArray($index);
13✔
308
            if ($segments !== []) {
13✔
309
                self::unsetByDotPath($result, $segments);
13✔
310
            }
311
        }
312

313
        return $result;
16✔
314
    }
315

316
    /**
317
     * Groups all rows by their index values. Result's depth equals number of indexes
318
     *
319
     * @used-by array_group_by()
320
     *
321
     * @param array $array        Data array (i.e. from query result)
322
     * @param array $indexes      Indexes to group by. Dot syntax used. Returns $array if empty
323
     * @param bool  $includeEmpty If true, null and '' are also added as valid keys to group
324
     *
325
     * @return array Result array where rows are grouped together by indexes values.
326
     */
327
    public static function groupBy(array $array, array $indexes, bool $includeEmpty = false): array
328
    {
329
        if ($indexes === []) {
8✔
330
            return $array;
×
331
        }
332

333
        $result = [];
8✔
334

335
        foreach ($array as $row) {
8✔
336
            $result = self::arrayAttachIndexedValue($result, $row, $indexes, $includeEmpty);
8✔
337
        }
338

339
        return $result;
8✔
340
    }
341

342
    /**
343
     * Recursively attach $row to the $indexes path of values found by
344
     * dot syntax.
345
     *
346
     * @used-by groupBy()
347
     *
348
     * @param array<array-key, mixed>|object $row
349
     * @param list<string>                   $indexes
350
     */
351
    private static function arrayAttachIndexedValue(
352
        array $result,
353
        array|object $row,
354
        array $indexes,
355
        bool $includeEmpty,
356
    ): array {
357
        if (($index = array_shift($indexes)) === null) {
8✔
358
            $result[] = $row;
8✔
359

360
            return $result;
8✔
361
        }
362

363
        $value = self::dotSearch($index, $row);
8✔
364

365
        if (! is_scalar($value)) {
8✔
366
            $value = '';
6✔
367
        }
368

369
        if (is_bool($value)) {
8✔
370
            $value = (int) $value;
×
371
        }
372

373
        if (! $includeEmpty && $value === '') {
8✔
374
            return $result;
3✔
375
        }
376

377
        if (! array_key_exists($value, $result)) {
8✔
378
            $result[$value] = [];
8✔
379
        }
380

381
        $result[$value] = self::arrayAttachIndexedValue($result[$value], $row, $indexes, $includeEmpty);
8✔
382

383
        return $result;
8✔
384
    }
385

386
    /**
387
     * Compare recursively two associative arrays and return difference as new array.
388
     * Returns keys that exist in `$original` but not in `$compareWith`.
389
     */
390
    public static function recursiveDiff(array $original, array $compareWith): array
391
    {
392
        $difference = [];
12✔
393

394
        if ($original === []) {
12✔
395
            return [];
1✔
396
        }
397

398
        if ($compareWith === []) {
11✔
399
            return $original;
6✔
400
        }
401

402
        foreach ($original as $originalKey => $originalValue) {
5✔
403
            if ($originalValue === []) {
5✔
404
                continue;
4✔
405
            }
406

407
            if (is_array($originalValue)) {
5✔
408
                $diffArrays = [];
5✔
409

410
                if (isset($compareWith[$originalKey]) && is_array($compareWith[$originalKey])) {
5✔
411
                    $diffArrays = self::recursiveDiff($originalValue, $compareWith[$originalKey]);
4✔
412
                } else {
413
                    $difference[$originalKey] = $originalValue;
2✔
414
                }
415

416
                if ($diffArrays !== []) {
5✔
417
                    $difference[$originalKey] = $diffArrays;
1✔
418
                }
419
            } elseif (is_string($originalValue) && ! array_key_exists($originalKey, $compareWith)) {
4✔
420
                $difference[$originalKey] = $originalValue;
1✔
421
            }
422
        }
423

424
        return $difference;
5✔
425
    }
426

427
    /**
428
     * Recursively count all keys.
429
     */
430
    public static function recursiveCount(array $array, int $counter = 0): int
431
    {
432
        foreach ($array as $value) {
8✔
433
            if (is_array($value)) {
8✔
434
                $counter = self::recursiveCount($value, $counter);
7✔
435
            }
436

437
            $counter++;
8✔
438
        }
439

440
        return $counter;
8✔
441
    }
442

443
    /**
444
     * Sorts array values in natural order
445
     * If the value is an array, you need to specify the $sortByIndex of the key to sort
446
     *
447
     * @param list<int|list<int|string>|string> $array
448
     * @param int|string|null                   $sortByIndex
449
     */
450
    public static function sortValuesByNatural(array &$array, $sortByIndex = null): bool
451
    {
452
        return usort($array, static function ($currentValue, $nextValue) use ($sortByIndex): int {
3✔
453
            if ($sortByIndex !== null) {
3✔
454
                return strnatcmp((string) $currentValue[$sortByIndex], (string) $nextValue[$sortByIndex]);
2✔
455
            }
456

457
            return strnatcmp((string) $currentValue, (string) $nextValue);
1✔
458
        });
3✔
459
    }
460

461
    /**
462
     * @param array<array-key, mixed>|object $data
463
     */
464
    private static function keyExists(array|object $data, string $key): bool
465
    {
466
        if (is_array($data)) {
127✔
467
            return array_key_exists($key, $data);
127✔
468
        }
469

470
        return array_key_exists($key, get_object_vars($data));
3✔
471
    }
472

473
    /**
474
     * @param array<array-key, mixed>|object $data
475
     */
476
    private static function valueExists(array|object $data, string $key): bool
477
    {
478
        if (is_array($data)) {
1,696✔
479
            return isset($data[$key]);
1,694✔
480
        }
481

482
        return isset(get_object_vars($data)[$key]);
4✔
483
    }
484

485
    /**
486
     * @param array<array-key, mixed>|object $data
487
     */
488
    private static function value(array|object $data, string $key): mixed
489
    {
490
        if (is_array($data)) {
1,519✔
491
            return $data[$key];
1,517✔
492
        }
493

494
        return get_object_vars($data)[$key];
5✔
495
    }
496

497
    /**
498
     * Throws exception for invalid wildcard patterns.
499
     */
500
    private static function ensureValidWildcardPattern(string $index, bool $allowTrailingWildcard = false): void
501
    {
502
        if ((! $allowTrailingWildcard && str_ends_with($index, '*')) || str_contains($index, '*.*')) {
167✔
503
            throw new InvalidArgumentException(
7✔
504
                'You must set key right after "*". Invalid index: "' . $index . '"',
7✔
505
            );
7✔
506
        }
507
    }
508

509
    /**
510
     * Set value recursively by dot path, including wildcard support.
511
     *
512
     * @param array<array-key, mixed> $array
513
     * @param list<string>            $indexes
514
     */
515
    private static function setByDotPath(array &$array, array $indexes, mixed $value): void
516
    {
517
        if ($indexes === []) {
55✔
NEW
518
            return;
×
519
        }
520

521
        $currentIndex = array_shift($indexes);
55✔
522

523
        if ($currentIndex === '*') {
55✔
524
            foreach ($array as &$item) {
3✔
525
                if (! is_array($item)) {
3✔
526
                    continue;
1✔
527
                }
528

529
                self::setByDotPath($item, $indexes, $value);
3✔
530
            }
531
            unset($item);
3✔
532

533
            return;
3✔
534
        }
535

536
        if ($indexes === []) {
55✔
537
            $array[$currentIndex] = $value;
55✔
538

539
            return;
55✔
540
        }
541

542
        if (! isset($array[$currentIndex]) || ! is_array($array[$currentIndex])) {
49✔
543
            $array[$currentIndex] = [];
43✔
544
        }
545

546
        self::setByDotPath($array[$currentIndex], $indexes, $value);
49✔
547
    }
548

549
    /**
550
     * Unset value recursively by dot path, including wildcard support.
551
     *
552
     * @param array<array-key, mixed> $array
553
     * @param list<string>            $indexes
554
     */
555
    private static function unsetByDotPath(array &$array, array $indexes): int
556
    {
557
        if ($indexes === []) {
26✔
NEW
558
            return 0;
×
559
        }
560

561
        $currentIndex = array_shift($indexes);
26✔
562

563
        if ($currentIndex === '*') {
26✔
564
            $removed = 0;
4✔
565

566
            foreach ($array as &$item) {
4✔
567
                if (! is_array($item)) {
4✔
NEW
568
                    continue;
×
569
                }
570

571
                $removed += self::unsetByDotPath($item, $indexes);
4✔
572
            }
573
            unset($item);
4✔
574

575
            return $removed;
4✔
576
        }
577

578
        if ($indexes === []) {
26✔
579
            if (! array_key_exists($currentIndex, $array)) {
25✔
580
                return 0;
5✔
581
            }
582

583
            unset($array[$currentIndex]);
24✔
584

585
            return 1;
24✔
586
        }
587

588
        if (! isset($array[$currentIndex]) || ! is_array($array[$currentIndex])) {
22✔
589
            return 0;
1✔
590
        }
591

592
        return self::unsetByDotPath($array[$currentIndex], $indexes);
22✔
593
    }
594

595
    /**
596
     * Clears all children under the specified path.
597
     *
598
     * @param array<array-key, mixed> $array
599
     * @param list<string>            $indexes
600
     */
601
    private static function clearByDotPath(array &$array, array $indexes): int
602
    {
603
        if ($indexes === []) {
5✔
604
            $count = count($array);
5✔
605
            $array = [];
5✔
606

607
            return $count;
5✔
608
        }
609

610
        $currentIndex = array_shift($indexes);
4✔
611

612
        if ($currentIndex === '*') {
4✔
613
            $cleared = 0;
×
614

615
            foreach ($array as &$item) {
×
616
                if (! is_array($item)) {
×
617
                    continue;
×
618
                }
619

620
                $cleared += self::clearByDotPath($item, $indexes);
×
621
            }
622
            unset($item);
×
623

624
            return $cleared;
×
625
        }
626

627
        if (! array_key_exists($currentIndex, $array) || ! is_array($array[$currentIndex])) {
4✔
628
            return 0;
×
629
        }
630

631
        return self::clearByDotPath($array[$currentIndex], $indexes);
4✔
632
    }
633

634
    /**
635
     * Projects matching paths from source array into result with preserved structure.
636
     *
637
     * @param list<string>            $indexes
638
     * @param list<string>            $prefix
639
     * @param array<array-key, mixed> $result
640
     */
641
    private static function projectByDotPath(
642
        mixed $source,
643
        array $indexes,
644
        array &$result,
645
        array $prefix = [],
646
    ): void {
647
        if ($indexes === []) {
17✔
648
            self::setByDotPath($result, $prefix, $source);
17✔
649

650
            return;
17✔
651
        }
652

653
        $currentIndex = array_shift($indexes);
17✔
654

655
        if ($currentIndex === '*') {
17✔
656
            if (! is_array($source) && ! is_object($source)) {
2✔
657
                return;
×
658
            }
659

660
            foreach ($source as $key => $value) {
2✔
661
                self::projectByDotPath($value, $indexes, $result, [...$prefix, (string) $key]);
2✔
662
            }
663

664
            return;
2✔
665
        }
666

667
        if ((! is_array($source) && ! is_object($source)) || ! self::keyExists($source, $currentIndex)) {
17✔
668
            return;
8✔
669
        }
670

671
        self::projectByDotPath(self::value($source, $currentIndex), $indexes, $result, [...$prefix, $currentIndex]);
17✔
672
    }
673
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc