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

codeigniter4 / CodeIgniter4 / 26789341801

01 Jun 2026 11:52PM UTC coverage: 88.556% (+0.003%) from 88.553%
26789341801

Pull #10259

github

web-flow
Merge d150c2fe8 into 12888fbec
Pull Request #10259: feat: expose prepared `FormRequest` data during validation failure

13 of 13 new or added lines in 1 file covered. (100.0%)

25 existing lines in 1 file now uncovered.

24305 of 27446 relevant lines covered (88.56%)

223.8 hits per line

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

89.71
/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 ArrayAccess;
17
use CodeIgniter\Entity\Entity;
18
use CodeIgniter\Exceptions\InvalidArgumentException;
19
use Traversable;
20

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

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

59
        if ($trimmed === '') {
1,825✔
60
            return [];
10✔
61
        }
62

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

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

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

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

97
        // Grab the current index
98
        $currentIndex = array_shift($indexes);
1,719✔
99

100
        if (! self::valueExists($array, $currentIndex) && $currentIndex !== '*') {
1,719✔
101
            return null;
288✔
102
        }
103

104
        // Handle Wildcard (*)
105
        if ($currentIndex === '*') {
1,478✔
106
            $answer   = [];
10✔
107
            $iterable = is_object($array) ? self::toIterable($array) : $array;
10✔
108

109
            foreach ($iterable as $value) {
10✔
110
                if (! is_array($value) && ! is_object($value)) {
10✔
111
                    return null;
1✔
112
                }
113

114
                $answer[] = self::arraySearchDot($indexes, $value);
9✔
115
            }
116

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

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

124
            return null;
1✔
125
        }
126

127
        // If this is the last index, make sure to return it now,
128
        // and not try to recurse through things.
129
        if ($indexes === []) {
1,478✔
130
            return self::value($array, $currentIndex);
1,465✔
131
        }
132

133
        $value = self::value($array, $currentIndex);
83✔
134

135
        // Do we need to recursively search this value?
136
        if ((is_array($value) && $value !== []) || is_object($value)) {
83✔
137
            return self::arraySearchDot($indexes, $value);
80✔
138
        }
139

140
        // Otherwise, not found.
141
        return null;
6✔
142
    }
143

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

155
        $indexes = self::convertToArray($index);
113✔
156

157
        if ($indexes === []) {
113✔
158
            return false;
1✔
159
        }
160

161
        return self::hasByDotPath($array, $indexes);
113✔
162
    }
163

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

176
        $currentIndex = array_shift($indexes);
113✔
177

178
        if ($currentIndex === '*') {
113✔
179
            foreach ($array as $item) {
15✔
180
                if (! is_array($item) || ! self::hasByDotPath($item, $indexes)) {
15✔
181
                    return false;
10✔
182
                }
183
            }
184

185
            return true;
8✔
186
        }
187

188
        if (! array_key_exists($currentIndex, $array)) {
113✔
189
            return false;
50✔
190
        }
191

192
        if ($indexes === []) {
95✔
193
            return true;
88✔
194
        }
195

196
        if (! is_array($array[$currentIndex])) {
59✔
UNCOV
197
            return false;
×
198
        }
199

200
        return self::hasByDotPath($array[$currentIndex], $indexes);
59✔
201
    }
202

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

212
        $indexes = self::convertToArray($index);
42✔
213

214
        if ($indexes === []) {
42✔
UNCOV
215
            return;
×
216
        }
217

218
        self::setByDotPath($array, $indexes, $value);
42✔
219
    }
220

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

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

234
        $indexes = self::convertToArray($index);
15✔
235

236
        if ($indexes === []) {
15✔
UNCOV
237
            return false;
×
238
        }
239

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

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

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

260
        foreach ($indexes as $index) {
17✔
261
            self::ensureValidWildcardPattern($index, true);
17✔
262

263
            if ($index === '*') {
17✔
264
                $result = [...$result, ...$array];
1✔
265

266
                continue;
1✔
267
            }
268

269
            $segments = self::convertToArray($index);
16✔
270
            if ($segments === []) {
16✔
UNCOV
271
                continue;
×
272
            }
273

274
            self::projectByDotPath($array, $segments, $result);
16✔
275
        }
276

277
        return $result;
17✔
278
    }
279

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

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

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

299
                continue;
1✔
300
            }
301

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

306
                continue;
2✔
307
            }
308

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

315
        return $result;
16✔
316
    }
317

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

335
        $result = [];
9✔
336

337
        foreach ($array as $row) {
9✔
338
            $result = self::arrayAttachIndexedValue($result, $row, $indexes, $includeEmpty);
9✔
339
        }
340

341
        return $result;
9✔
342
    }
343

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

362
            return $result;
9✔
363
        }
364

365
        $value = self::dotSearch($index, $row);
9✔
366

367
        if (! is_scalar($value)) {
9✔
368
            $value = '';
6✔
369
        }
370

371
        if (is_bool($value)) {
9✔
UNCOV
372
            $value = (int) $value;
×
373
        }
374

375
        if (! $includeEmpty && $value === '') {
9✔
376
            return $result;
3✔
377
        }
378

379
        if (! array_key_exists($value, $result)) {
9✔
380
            $result[$value] = [];
9✔
381
        }
382

383
        $result[$value] = self::arrayAttachIndexedValue($result[$value], $row, $indexes, $includeEmpty);
9✔
384

385
        return $result;
9✔
386
    }
387

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

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

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

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

409
            if (is_array($originalValue)) {
5✔
410
                $diffArrays = [];
5✔
411

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

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

426
        return $difference;
5✔
427
    }
428

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

439
            $counter++;
8✔
440
        }
441

442
        return $counter;
8✔
443
    }
444

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

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

463
    /**
464
     * @param array<array-key, mixed>|object $data
465
     */
466
    private static function valueExists(array|object $data, string $key): bool
467
    {
468
        if (is_array($data)) {
1,719✔
469
            return isset($data[$key]);
1,716✔
470
        }
471

472
        $array = self::entityToArray($data);
8✔
473

474
        if ($array !== null) {
8✔
475
            return isset($array[$key]);
2✔
476
        }
477

478
        if ($data instanceof ArrayAccess && $data->offsetExists($key)) {
6✔
479
            return true;
1✔
480
        }
481

482
        if (isset(get_object_vars($data)[$key])) {
5✔
483
            return true;
4✔
484
        }
485

486
        return isset($data->{$key});
1✔
487
    }
488

489
    /**
490
     * @param array<array-key, mixed>|object $data
491
     */
492
    private static function value(array|object $data, string $key): mixed
493
    {
494
        if (is_array($data)) {
1,478✔
495
            return $data[$key];
1,475✔
496
        }
497

498
        $array = self::entityToArray($data);
8✔
499

500
        if ($array !== null) {
8✔
501
            return $array[$key];
2✔
502
        }
503

504
        if ($data instanceof ArrayAccess && $data->offsetExists($key)) {
6✔
505
            return $data->offsetGet($key);
1✔
506
        }
507

508
        $properties = get_object_vars($data);
5✔
509

510
        if (array_key_exists($key, $properties)) {
5✔
511
            return $properties[$key];
4✔
512
        }
513

514
        return $data->{$key};
1✔
515
    }
516

517
    /**
518
     * @return array<array-key, mixed>|null
519
     */
520
    private static function entityToArray(object $data): ?array
521
    {
522
        if ($data instanceof Entity) {
8✔
523
            return $data->toArray();
2✔
524
        }
525

526
        return null;
6✔
527
    }
528

529
    /**
530
     * Normalize an object to an array safe to iterate with foreach.
531
     *
532
     * Entities are converted via toArray() so internal properties like
533
     * `_options` or `_cast` are not exposed. Other Traversable objects are
534
     * materialized; plain objects fall back to their public properties.
535
     *
536
     * @return array<array-key, mixed>
537
     */
538
    private static function toIterable(object $data): array
539
    {
UNCOV
540
        $array = self::entityToArray($data);
×
541

UNCOV
542
        if ($array !== null) {
×
UNCOV
543
            return $array;
×
544
        }
545

UNCOV
546
        if ($data instanceof Traversable) {
×
UNCOV
547
            return iterator_to_array($data, false);
×
548
        }
549

UNCOV
550
        return get_object_vars($data);
×
551
    }
552

553
    /**
554
     * Throws exception for invalid wildcard patterns.
555
     */
556
    private static function ensureValidWildcardPattern(string $index, bool $allowTrailingWildcard = false): void
557
    {
558
        if ((! $allowTrailingWildcard && str_ends_with($index, '*')) || str_contains($index, '*.*')) {
169✔
559
            throw new InvalidArgumentException(
7✔
560
                'You must set key right after "*". Invalid index: "' . $index . '"',
7✔
561
            );
7✔
562
        }
563
    }
564

565
    /**
566
     * Set value recursively by dot path, including wildcard support.
567
     *
568
     * @param array<array-key, mixed> $array
569
     * @param list<string>            $indexes
570
     */
571
    private static function setByDotPath(array &$array, array $indexes, mixed $value): void
572
    {
573
        if ($indexes === []) {
54✔
UNCOV
574
            return;
×
575
        }
576

577
        $currentIndex = array_shift($indexes);
54✔
578

579
        if ($currentIndex === '*') {
54✔
580
            foreach ($array as &$item) {
3✔
581
                if (! is_array($item)) {
3✔
582
                    continue;
1✔
583
                }
584

585
                self::setByDotPath($item, $indexes, $value);
3✔
586
            }
587
            unset($item);
3✔
588

589
            return;
3✔
590
        }
591

592
        if ($indexes === []) {
54✔
593
            $array[$currentIndex] = $value;
54✔
594

595
            return;
54✔
596
        }
597

598
        if (! isset($array[$currentIndex]) || ! is_array($array[$currentIndex])) {
48✔
599
            $array[$currentIndex] = [];
42✔
600
        }
601

602
        self::setByDotPath($array[$currentIndex], $indexes, $value);
48✔
603
    }
604

605
    /**
606
     * Unset value recursively by dot path, including wildcard support.
607
     *
608
     * @param array<array-key, mixed> $array
609
     * @param list<string>            $indexes
610
     */
611
    private static function unsetByDotPath(array &$array, array $indexes): int
612
    {
613
        if ($indexes === []) {
26✔
UNCOV
614
            return 0;
×
615
        }
616

617
        $currentIndex = array_shift($indexes);
26✔
618

619
        if ($currentIndex === '*') {
26✔
620
            $removed = 0;
4✔
621

622
            foreach ($array as &$item) {
4✔
623
                if (! is_array($item)) {
4✔
UNCOV
624
                    continue;
×
625
                }
626

627
                $removed += self::unsetByDotPath($item, $indexes);
4✔
628
            }
629
            unset($item);
4✔
630

631
            return $removed;
4✔
632
        }
633

634
        if ($indexes === []) {
26✔
635
            if (! array_key_exists($currentIndex, $array)) {
25✔
636
                return 0;
5✔
637
            }
638

639
            unset($array[$currentIndex]);
24✔
640

641
            return 1;
24✔
642
        }
643

644
        if (! isset($array[$currentIndex]) || ! is_array($array[$currentIndex])) {
22✔
645
            return 0;
1✔
646
        }
647

648
        return self::unsetByDotPath($array[$currentIndex], $indexes);
22✔
649
    }
650

651
    /**
652
     * Clears all children under the specified path.
653
     *
654
     * @param array<array-key, mixed> $array
655
     * @param list<string>            $indexes
656
     */
657
    private static function clearByDotPath(array &$array, array $indexes): int
658
    {
659
        if ($indexes === []) {
5✔
660
            $count = count($array);
5✔
661
            $array = [];
5✔
662

663
            return $count;
5✔
664
        }
665

666
        $currentIndex = array_shift($indexes);
4✔
667

668
        if ($currentIndex === '*') {
4✔
UNCOV
669
            $cleared = 0;
×
670

UNCOV
671
            foreach ($array as &$item) {
×
UNCOV
672
                if (! is_array($item)) {
×
UNCOV
673
                    continue;
×
674
                }
675

UNCOV
676
                $cleared += self::clearByDotPath($item, $indexes);
×
677
            }
UNCOV
678
            unset($item);
×
679

UNCOV
680
            return $cleared;
×
681
        }
682

683
        if (! array_key_exists($currentIndex, $array) || ! is_array($array[$currentIndex])) {
4✔
UNCOV
684
            return 0;
×
685
        }
686

687
        return self::clearByDotPath($array[$currentIndex], $indexes);
4✔
688
    }
689

690
    /**
691
     * Projects matching paths from source array into result with preserved structure.
692
     *
693
     * @param list<string>            $indexes
694
     * @param list<string>            $prefix
695
     * @param array<array-key, mixed> $result
696
     */
697
    private static function projectByDotPath(
698
        mixed $source,
699
        array $indexes,
700
        array &$result,
701
        array $prefix = [],
702
    ): void {
703
        if ($indexes === []) {
16✔
704
            self::setByDotPath($result, $prefix, $source);
16✔
705

706
            return;
16✔
707
        }
708

709
        $currentIndex = array_shift($indexes);
16✔
710

711
        if ($currentIndex === '*') {
16✔
712
            if (! is_array($source)) {
1✔
UNCOV
713
                return;
×
714
            }
715

716
            foreach ($source as $key => $value) {
1✔
717
                self::projectByDotPath($value, $indexes, $result, [...$prefix, (string) $key]);
1✔
718
            }
719

720
            return;
1✔
721
        }
722

723
        if (! is_array($source) || ! array_key_exists($currentIndex, $source)) {
16✔
724
            return;
8✔
725
        }
726

727
        self::projectByDotPath($source[$currentIndex], $indexes, $result, [...$prefix, $currentIndex]);
16✔
728
    }
729
}
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