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

© 2026 Coveralls, Inc