• 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 TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc