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

codeigniter4 / CodeIgniter4 / 22321291815

23 Feb 2026 07:22PM UTC coverage: 86.594% (-0.01%) from 86.604%
22321291815

push

github

web-flow
feat: add new dot-path array helpers (#9990)

* feat: add new dot-syntax helper functions

* optimize

* add docs

* add changelog

* update tests

126 of 143 new or added lines in 4 files covered. (88.11%)

1 existing line in 1 file now uncovered.

22343 of 25802 relevant lines covered (86.59%)

217.92 hits per line

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

90.52
/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
     *
39
     * @return array|bool|int|object|string|null
40
     */
41
    public static function dotSearch(string $index, array $array)
42
    {
43
        return self::arraySearchDot(self::convertToArray($index), $array);
1,609 ✔
44
    }
45

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

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

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

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

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

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

90
        // Grab the current index
91
        $currentIndex = array_shift($indexes);
1,600 ✔
92

93
        if (! isset($array[$currentIndex]) && $currentIndex !== '*') {
1,600 ✔
94
            return null;
254 ✔
95
        }
96

97
        // Handle Wildcard (*)
98
        if ($currentIndex === '*') {
1,390 ✔
99
            $answer = [];
9 ✔
100

101
            foreach ($array as $value) {
9 ✔
102
                if (! is_array($value)) {
9 ✔
103
                    return null;
1 ✔
104
                }
105

106
                $answer[] = self::arraySearchDot($indexes, $value);
8 ✔
107
            }
108

109
            $answer = array_filter($answer, static fn ($value): bool => $value !== null);
8 ✔
110

111
            if ($answer !== []) {
8 ✔
112
                // If array only has one element, we return that element for BC.
113
                return count($answer) === 1 ? current($answer) : $answer;
7 ✔
114
            }
115

116
            return null;
1 ✔
117
        }
118

119
        // If this is the last index, make sure to return it now,
120
        // and not try to recurse through things.
121
        if ($indexes === []) {
1,390 ✔
122
            return $array[$currentIndex];
1,377 ✔
123
        }
124

125
        // Do we need to recursively search this value?
126
        if (is_array($array[$currentIndex]) && $array[$currentIndex] !== []) {
58 ✔
127
            return self::arraySearchDot($indexes, $array[$currentIndex]);
55 ✔
128
        }
129

130
        // Otherwise, not found.
131
        return null;
6 ✔
132
    }
133

134
    /**
135
     * array_key_exists() with dot array syntax.
136
     *
137
     * If wildcard `*` is used, all items for the key after it must have the key.
138
     *
139
     * @param array<array-key, mixed> $array
140
     */
141
    public static function dotHas(string $index, array $array): bool
142
    {
143
        self::ensureValidWildcardPattern($index);
41 ✔
144

145
        $indexes = self::convertToArray($index);
38 ✔
146

147
        if ($indexes === []) {
38 ✔
148
            return false;
1 ✔
149
        }
150

151
        return self::hasByDotPath($array, $indexes);
38 ✔
152
    }
153

154
    /**
155
     * Recursively check key existence by dot path, including wildcard support.
156
     *
157
     * @param array<array-key, mixed> $array
158
     * @param list<string>            $indexes
159
     */
160
    private static function hasByDotPath(array $array, array $indexes): bool
161
    {
162
        if ($indexes === []) {
38 ✔
NEW
163
            return true;
×
164
        }
165

166
        $currentIndex = array_shift($indexes);
38 ✔
167

168
        if ($currentIndex === '*') {
38 ✔
169
            foreach ($array as $item) {
11 ✔
170
                if (! is_array($item) || ! self::hasByDotPath($item, $indexes)) {
11 ✔
171
                    return false;
8 ✔
172
                }
173
            }
174

175
            return true;
6 ✔
176
        }
177

178
        if (! array_key_exists($currentIndex, $array)) {
38 ✔
179
            return false;
19 ✔
180
        }
181

182
        if ($indexes === []) {
34 ✔
183
            return true;
28 ✔
184
        }
185

186
        if (! is_array($array[$currentIndex])) {
34 ✔
NEW
187
            return false;
×
188
        }
189

190
        return self::hasByDotPath($array[$currentIndex], $indexes);
34 ✔
191
    }
192

193
    /**
194
     * Sets a value by dot array syntax.
195
     *
196
     * @param array<array-key, mixed> $array
197
     */
198
    public static function dotSet(array &$array, string $index, mixed $value): void
199
    {
200
        self::ensureValidWildcardPattern($index);
11 ✔
201

202
        $indexes = self::convertToArray($index);
9 ✔
203

204
        if ($indexes === []) {
9 ✔
NEW
205
            return;
×
206
        }
207

208
        self::setByDotPath($array, $indexes, $value);
9 ✔
209
    }
210

211
    /**
212
     * Removes a value by dot array syntax.
213
     *
214
     * @param array<array-key, mixed> $array
215
     */
216
    public static function dotUnset(array &$array, string $index): bool
217
    {
218
        self::ensureValidWildcardPattern($index, true);
14 ✔
219

220
        if ($index === '*') {
12 ✔
221
            return self::clearByDotPath($array, []) > 0;
1 ✔
222
        }
223

224
        $indexes = self::convertToArray($index);
11 ✔
225

226
        if ($indexes === []) {
11 ✔
NEW
227
            return false;
×
228
        }
229

230
        if (str_ends_with($index, '*')) {
11 ✔
231
            return self::clearByDotPath($array, $indexes) > 0;
2 ✔
232
        }
233

234
        return self::unsetByDotPath($array, $indexes) > 0;
9 ✔
235
    }
236

237
    /**
238
     * Gets only the specified keys using dot syntax.
239
     *
240
     * @param array<array-key, mixed> $array
241
     * @param list<string>|string     $indexes
242
     *
243
     * @return array<array-key, mixed>
244
     */
245
    public static function dotOnly(array $array, array|string $indexes): array
246
    {
247
        $indexes = is_string($indexes) ? [$indexes] : $indexes;
9 ✔
248
        $result  = [];
9 ✔
249

250
        foreach ($indexes as $index) {
9 ✔
251
            self::ensureValidWildcardPattern($index, true);
9 ✔
252

253
            if ($index === '*') {
9 ✔
254
                $result = [...$result, ...$array];
1 ✔
255

256
                continue;
1 ✔
257
            }
258

259
            $segments = self::convertToArray($index);
8 ✔
260
            if ($segments === []) {
8 ✔
UNCOV
261
                continue;
×
262
            }
263

264
            self::projectByDotPath($array, $segments, $result);
8 ✔
265
        }
266

267
        return $result;
9 ✔
268
    }
269

270
    /**
271
     * Gets all keys except the specified ones using dot syntax.
272
     *
273
     * @param array<array-key, mixed> $array
274
     * @param list<string>|string     $indexes
275
     *
276
     * @return array<array-key, mixed>
277
     */
278
    public static function dotExcept(array $array, array|string $indexes): array
279
    {
280
        $indexes = is_string($indexes) ? [$indexes] : $indexes;
8 ✔
281
        $result  = $array;
8 ✔
282

283
        foreach ($indexes as $index) {
8 ✔
284
            self::ensureValidWildcardPattern($index, true);
8 ✔
285

286
            if ($index === '*') {
8 ✔
287
                $result = [];
1 ✔
288

289
                continue;
1 ✔
290
            }
291

292
            if (str_ends_with($index, '*')) {
7 ✔
293
                $segments = self::convertToArray($index);
2 ✔
294
                self::clearByDotPath($result, $segments);
2 ✔
295

296
                continue;
2 ✔
297
            }
298

299
            $segments = self::convertToArray($index);
5 ✔
300
            if ($segments !== []) {
5 ✔
301
                self::unsetByDotPath($result, $segments);
5 ✔
302
            }
303
        }
304

305
        return $result;
8 ✔
306
    }
307

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

325
        $result = [];
6 ✔
326

327
        foreach ($array as $row) {
6 ✔
328
            $result = self::arrayAttachIndexedValue($result, $row, $indexes, $includeEmpty);
6 ✔
329
        }
330

331
        return $result;
6 ✔
332
    }
333

334
    /**
335
     * Recursively attach $row to the $indexes path of values found by
336
     * `dot_array_search()`.
337
     *
338
     * @used-by groupBy()
339
     */
340
    private static function arrayAttachIndexedValue(
341
        array $result,
342
        array $row,
343
        array $indexes,
344
        bool $includeEmpty,
345
    ): array {
346
        if (($index = array_shift($indexes)) === null) {
6 ✔
347
            $result[] = $row;
6 ✔
348

349
            return $result;
6 ✔
350
        }
351

352
        $value = dot_array_search($index, $row);
6 ✔
353

354
        if (! is_scalar($value)) {
6 ✔
355
            $value = '';
6 ✔
356
        }
357

358
        if (is_bool($value)) {
6 ✔
359
            $value = (int) $value;
×
360
        }
361

362
        if (! $includeEmpty && $value === '') {
6 ✔
363
            return $result;
3 ✔
364
        }
365

366
        if (! array_key_exists($value, $result)) {
6 ✔
367
            $result[$value] = [];
6 ✔
368
        }
369

370
        $result[$value] = self::arrayAttachIndexedValue($result[$value], $row, $indexes, $includeEmpty);
6 ✔
371

372
        return $result;
6 ✔
373
    }
374

375
    /**
376
     * Compare recursively two associative arrays and return difference as new array.
377
     * Returns keys that exist in `$original` but not in `$compareWith`.
378
     */
379
    public static function recursiveDiff(array $original, array $compareWith): array
380
    {
381
        $difference = [];
12 ✔
382

383
        if ($original === []) {
12 ✔
384
            return [];
1 ✔
385
        }
386

387
        if ($compareWith === []) {
11 ✔
388
            return $original;
6 ✔
389
        }
390

391
        foreach ($original as $originalKey => $originalValue) {
5 ✔
392
            if ($originalValue === []) {
5 ✔
393
                continue;
4 ✔
394
            }
395

396
            if (is_array($originalValue)) {
5 ✔
397
                $diffArrays = [];
5 ✔
398

399
                if (isset($compareWith[$originalKey]) && is_array($compareWith[$originalKey])) {
5 ✔
400
                    $diffArrays = self::recursiveDiff($originalValue, $compareWith[$originalKey]);
4 ✔
401
                } else {
402
                    $difference[$originalKey] = $originalValue;
2 ✔
403
                }
404

405
                if ($diffArrays !== []) {
5 ✔
406
                    $difference[$originalKey] = $diffArrays;
1 ✔
407
                }
408
            } elseif (is_string($originalValue) && ! array_key_exists($originalKey, $compareWith)) {
4 ✔
409
                $difference[$originalKey] = $originalValue;
1 ✔
410
            }
411
        }
412

413
        return $difference;
5 ✔
414
    }
415

416
    /**
417
     * Recursively count all keys.
418
     */
419
    public static function recursiveCount(array $array, int $counter = 0): int
420
    {
421
        foreach ($array as $value) {
8 ✔
422
            if (is_array($value)) {
8 ✔
423
                $counter = self::recursiveCount($value, $counter);
7 ✔
424
            }
425

426
            $counter++;
8 ✔
427
        }
428

429
        return $counter;
8 ✔
430
    }
431

432
    /**
433
     * Sorts array values in natural order
434
     * If the value is an array, you need to specify the $sortByIndex of the key to sort
435
     *
436
     * @param list<int|list<int|string>|string> $array
437
     * @param int|string|null                   $sortByIndex
438
     */
439
    public static function sortValuesByNatural(array &$array, $sortByIndex = null): bool
440
    {
441
        return usort($array, static function ($currentValue, $nextValue) use ($sortByIndex): int {
3 ✔
442
            if ($sortByIndex !== null) {
3 ✔
443
                return strnatcmp((string) $currentValue[$sortByIndex], (string) $nextValue[$sortByIndex]);
2 ✔
444
            }
445

446
            return strnatcmp((string) $currentValue, (string) $nextValue);
1 ✔
447
        });
3 ✔
448
    }
449

450
    /**
451
     * Throws exception for invalid wildcard patterns.
452
     */
453
    private static function ensureValidWildcardPattern(string $index, bool $allowTrailingWildcard = false): void
454
    {
455
        if ((! $allowTrailingWildcard && str_ends_with($index, '*')) || str_contains($index, '*.*')) {
75 ✔
456
            throw new InvalidArgumentException(
7 ✔
457
                'You must set key right after "*". Invalid index: "' . $index . '"',
7 ✔
458
            );
7 ✔
459
        }
460
    }
461

462
    /**
463
     * Set value recursively by dot path, including wildcard support.
464
     *
465
     * @param array<array-key, mixed> $array
466
     * @param list<string>            $indexes
467
     */
468
    private static function setByDotPath(array &$array, array $indexes, mixed $value): void
469
    {
470
        if ($indexes === []) {
17 ✔
NEW
471
            return;
×
472
        }
473

474
        $currentIndex = array_shift($indexes);
17 ✔
475

476
        if ($currentIndex === '*') {
17 ✔
477
            foreach ($array as &$item) {
3 ✔
478
                if (! is_array($item)) {
3 ✔
479
                    continue;
1 ✔
480
                }
481

482
                self::setByDotPath($item, $indexes, $value);
3 ✔
483
            }
484
            unset($item);
3 ✔
485

486
            return;
3 ✔
487
        }
488

489
        if ($indexes === []) {
17 ✔
490
            $array[$currentIndex] = $value;
17 ✔
491

492
            return;
17 ✔
493
        }
494

495
        if (! isset($array[$currentIndex]) || ! is_array($array[$currentIndex])) {
15 ✔
496
            $array[$currentIndex] = [];
9 ✔
497
        }
498

499
        self::setByDotPath($array[$currentIndex], $indexes, $value);
15 ✔
500
    }
501

502
    /**
503
     * Unset value recursively by dot path, including wildcard support.
504
     *
505
     * @param array<array-key, mixed> $array
506
     * @param list<string>            $indexes
507
     */
508
    private static function unsetByDotPath(array &$array, array $indexes): int
509
    {
510
        if ($indexes === []) {
14 ✔
NEW
511
            return 0;
×
512
        }
513

514
        $currentIndex = array_shift($indexes);
14 ✔
515

516
        if ($currentIndex === '*') {
14 ✔
517
            $removed = 0;
4 ✔
518

519
            foreach ($array as &$item) {
4 ✔
520
                if (! is_array($item)) {
4 ✔
NEW
521
                    continue;
×
522
                }
523

524
                $removed += self::unsetByDotPath($item, $indexes);
4 ✔
525
            }
526
            unset($item);
4 ✔
527

528
            return $removed;
4 ✔
529
        }
530

531
        if ($indexes === []) {
14 ✔
532
            if (! array_key_exists($currentIndex, $array)) {
13 ✔
533
                return 0;
1 ✔
534
            }
535

536
            unset($array[$currentIndex]);
12 ✔
537

538
            return 1;
12 ✔
539
        }
540

541
        if (! isset($array[$currentIndex]) || ! is_array($array[$currentIndex])) {
14 ✔
542
            return 0;
1 ✔
543
        }
544

545
        return self::unsetByDotPath($array[$currentIndex], $indexes);
14 ✔
546
    }
547

548
    /**
549
     * Clears all children under the specified path.
550
     *
551
     * @param array<array-key, mixed> $array
552
     * @param list<string>            $indexes
553
     */
554
    private static function clearByDotPath(array &$array, array $indexes): int
555
    {
556
        if ($indexes === []) {
5 ✔
557
            $count = count($array);
5 ✔
558
            $array = [];
5 ✔
559

560
            return $count;
5 ✔
561
        }
562

563
        $currentIndex = array_shift($indexes);
4 ✔
564

565
        if ($currentIndex === '*') {
4 ✔
NEW
566
            $cleared = 0;
×
567

NEW
568
            foreach ($array as &$item) {
×
NEW
569
                if (! is_array($item)) {
×
NEW
570
                    continue;
×
571
                }
572

NEW
573
                $cleared += self::clearByDotPath($item, $indexes);
×
574
            }
NEW
575
            unset($item);
×
576

NEW
577
            return $cleared;
×
578
        }
579

580
        if (! array_key_exists($currentIndex, $array) || ! is_array($array[$currentIndex])) {
4 ✔
NEW
581
            return 0;
×
582
        }
583

584
        return self::clearByDotPath($array[$currentIndex], $indexes);
4 ✔
585
    }
586

587
    /**
588
     * Projects matching paths from source array into result with preserved structure.
589
     *
590
     * @param list<string>            $indexes
591
     * @param list<string>            $prefix
592
     * @param array<array-key, mixed> $result
593
     */
594
    private static function projectByDotPath(
595
        mixed $source,
596
        array $indexes,
597
        array &$result,
598
        array $prefix = [],
599
    ): void {
600
        if ($indexes === []) {
8 ✔
601
            self::setByDotPath($result, $prefix, $source);
8 ✔
602

603
            return;
8 ✔
604
        }
605

606
        $currentIndex = array_shift($indexes);
8 ✔
607

608
        if ($currentIndex === '*') {
8 ✔
609
            if (! is_array($source)) {
1 ✔
NEW
610
                return;
×
611
            }
612

613
            foreach ($source as $key => $value) {
1 ✔
614
                self::projectByDotPath($value, $indexes, $result, [...$prefix, (string) $key]);
1 ✔
615
            }
616

617
            return;
1 ✔
618
        }
619

620
        if (! is_array($source) || ! array_key_exists($currentIndex, $source)) {
8 ✔
NEW
621
            return;
×
622
        }
623

624
        self::projectByDotPath($source[$currentIndex], $indexes, $result, [...$prefix, $currentIndex]);
8 ✔
625
    }
626
}
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