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

codeigniter4 / CodeIgniter4 / 27461882718

13 Jun 2026 08:39AM UTC coverage: 89.056% (+0.07%) from 88.991%
27461882718

Pull #10302

github

web-flow
Merge b8adcfc91 into 3f961107f
Pull Request #10302: feat: add strict field protection for Models

28 of 31 new or added lines in 4 files covered. (90.32%)

43 existing lines in 6 files now uncovered.

24892 of 27951 relevant lines covered (89.06%)

225.43 hits per line

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

88.62
/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 stdClass;
20
use Traversable;
21

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

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

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

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

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

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

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

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

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

105
            foreach (self::entries($array) as $value) {
10✔
106
                if (! self::isNavigable($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
        [$found, $value] = self::resolve($array, $currentIndex);
1,725✔
124

125
        if (! $found) {
1,725✔
126
            return null;
162✔
127
        }
128

129
        // If this is the last index, make sure to return it now,
130
        // and not try to recurse through things.
131
        if ($indexes === []) {
1,603✔
132
            return $value;
1,596✔
133
        }
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>|object $array
150
     */
151
    public static function dotHas(string $index, array|object $array): bool
152
    {
153
        self::ensureValidWildcardPattern($index);
121✔
154

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

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

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

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

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

178
        if ($currentIndex === '*') {
118✔
179
            foreach (self::entries($array) as $item) {
16✔
180
                if (! self::isNavigable($item) || ! self::hasByDotPath($item, $indexes)) {
16✔
181
                    return false;
11✔
182
                }
183
            }
184

185
            return true;
9✔
186
        }
187

188
        [$found, $value] = self::resolve($array, $currentIndex);
118✔
189

190
        if (! $found) {
118✔
191
            return false;
53✔
192
        }
193

194
        if ($indexes === []) {
100✔
195
            return true;
93✔
196
        }
197

198
        if (! self::isNavigable($value)) {
64✔
UNCOV
199
            return false;
×
200
        }
201

202
        return self::hasByDotPath($value, $indexes);
64✔
203
    }
204

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

214
        $indexes = self::convertToArray($index);
42✔
215

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

220
        self::setByDotPath($array, $indexes, $value);
42✔
221
    }
222

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

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

236
        $indexes = self::convertToArray($index);
15✔
237

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

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

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

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

262
        foreach ($indexes as $index) {
22✔
263
            self::ensureValidWildcardPattern($index, true);
22✔
264

265
            if ($index === '*') {
22✔
266
                $result = [...$result, ...(is_object($array) ? self::toIterable($array) : $array)];
1✔
267

268
                continue;
1✔
269
            }
270

271
            $segments = self::convertToArray($index);
21✔
272
            if ($segments === []) {
21✔
UNCOV
273
                continue;
×
274
            }
275

276
            self::projectByDotPath($array, $segments, $result);
21✔
277
        }
278

279
        return $result;
22✔
280
    }
281

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

294
        // Open only the root into an array view; nested values (including
295
        // objects) are preserved until a path actually descends into them.
296
        $result = self::entries($array);
22✔
297

298
        foreach ($indexes as $index) {
22✔
299
            self::ensureValidWildcardPattern($index, true);
22✔
300

301
            if ($index === '*') {
22✔
302
                $result = [];
1✔
303

304
                continue;
1✔
305
            }
306

307
            $segments = self::convertToArray($index);
21✔
308
            if ($segments === []) {
21✔
UNCOV
309
                continue;
×
310
            }
311

312
            if (str_ends_with($index, '*')) {
21✔
313
                self::excludeChildrenByDotPath($result, $segments);
3✔
314

315
                continue;
3✔
316
            }
317

318
            self::excludeByDotPath($result, $segments);
18✔
319
        }
320

321
        return $result;
22✔
322
    }
323

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

341
        $result = [];
9✔
342

343
        foreach ($array as $row) {
9✔
344
            $result = self::arrayAttachIndexedValue($result, $row, $indexes, $includeEmpty);
9✔
345
        }
346

347
        return $result;
9✔
348
    }
349

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

368
            return $result;
9✔
369
        }
370

371
        $value = self::dotSearch($index, $row);
9✔
372

373
        if (! is_scalar($value)) {
9✔
374
            $value = '';
6✔
375
        }
376

377
        if (is_bool($value)) {
9✔
UNCOV
378
            $value = (int) $value;
×
379
        }
380

381
        if (! $includeEmpty && $value === '') {
9✔
382
            return $result;
3✔
383
        }
384

385
        if (! array_key_exists($value, $result)) {
9✔
386
            $result[$value] = [];
9✔
387
        }
388

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

391
        return $result;
9✔
392
    }
393

394
    /**
395
     * Compare recursively two associative arrays and return difference as new array.
396
     * Returns keys that exist in `$original` but not in `$compareWith`.
397
     */
398
    public static function recursiveDiff(array $original, array $compareWith): array
399
    {
400
        $difference = [];
12✔
401

402
        if ($original === []) {
12✔
403
            return [];
1✔
404
        }
405

406
        if ($compareWith === []) {
11✔
407
            return $original;
6✔
408
        }
409

410
        foreach ($original as $originalKey => $originalValue) {
5✔
411
            if ($originalValue === []) {
5✔
412
                continue;
4✔
413
            }
414

415
            if (is_array($originalValue)) {
5✔
416
                $diffArrays = [];
5✔
417

418
                if (isset($compareWith[$originalKey]) && is_array($compareWith[$originalKey])) {
5✔
419
                    $diffArrays = self::recursiveDiff($originalValue, $compareWith[$originalKey]);
4✔
420
                } else {
421
                    $difference[$originalKey] = $originalValue;
2✔
422
                }
423

424
                if ($diffArrays !== []) {
5✔
425
                    $difference[$originalKey] = $diffArrays;
1✔
426
                }
427
            } elseif (is_string($originalValue) && ! array_key_exists($originalKey, $compareWith)) {
4✔
428
                $difference[$originalKey] = $originalValue;
1✔
429
            }
430
        }
431

432
        return $difference;
5✔
433
    }
434

435
    /**
436
     * Recursively count all keys.
437
     */
438
    public static function recursiveCount(array $array, int $counter = 0): int
439
    {
440
        foreach ($array as $value) {
8✔
441
            if (is_array($value)) {
8✔
442
                $counter = self::recursiveCount($value, $counter);
7✔
443
            }
444

445
            $counter++;
8✔
446
        }
447

448
        return $counter;
8✔
449
    }
450

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

465
            return strnatcmp((string) $currentValue, (string) $nextValue);
1✔
466
        });
3✔
467
    }
468

469
    /**
470
     * Resolve a key against an array or object node, walking the access chain
471
     * (Entity, ArrayAccess, public properties, magic `__isset`/`__get`) once.
472
     *
473
     * @param array<array-key, mixed>|object $node
474
     *
475
     * @return array{bool, mixed} The pair [found, value].
476
     */
477
    private static function resolve(array|object $node, string $key): array
478
    {
479
        if (is_array($node)) {
1,802✔
480
            return array_key_exists($key, $node) ? [true, $node[$key]] : [false, null];
1,798✔
481
        }
482

483
        $array = self::entityToArray($node);
17✔
484

485
        if ($array !== null) {
17✔
486
            return array_key_exists($key, $array) ? [true, $array[$key]] : [false, null];
5✔
487
        }
488

489
        if ($node instanceof ArrayAccess && $node->offsetExists($key)) {
12✔
490
            return [true, $node->offsetGet($key)];
2✔
491
        }
492

493
        $properties = get_object_vars($node);
10✔
494

495
        if (array_key_exists($key, $properties)) {
10✔
496
            return [true, $properties[$key]];
8✔
497
        }
498

499
        return isset($node->{$key}) ? [true, $node->{$key}] : [false, null];
3✔
500
    }
501

502
    /**
503
     * Whether keys can be resolved from this value, i.e. it is an array or an
504
     * object that exposes a key surface: an expandable container, an
505
     * `ArrayAccess`, or one relying on magic `__get`. Pure value-objects
506
     * (e.g. `DateTimeImmutable`) are not navigable.
507
     *
508
     * Direct key lookup can support more object types than wildcard traversal:
509
     * `ArrayAccess` and magic-only objects can resolve `user.id`, but cannot be
510
     * enumerated for `user.*` unless they are also expandable.
511
     */
512
    private static function isNavigable(mixed $value): bool
513
    {
514
        if (is_array($value)) {
95✔
515
            return true;
83✔
516
        }
517

518
        return is_object($value)
26✔
519
            && (self::isExpandable($value)
26✔
520
                || $value instanceof ArrayAccess
26✔
521
                || method_exists($value, '__get'));
26✔
522
    }
523

524
    /**
525
     * Entries of an array or object node for wildcard traversal.
526
     *
527
     * @param array<array-key, mixed>|object $node
528
     *
529
     * @return array<array-key, mixed>
530
     */
531
    private static function entries(array|object $node): array
532
    {
533
        return is_object($node) ? self::toIterable($node) : $node;
50✔
534
    }
535

536
    /**
537
     * @return array<array-key, mixed>|null
538
     */
539
    private static function entityToArray(object $data): ?array
540
    {
541
        if ($data instanceof Entity) {
20✔
542
            return $data->toArray();
5✔
543
        }
544

545
        return null;
15✔
546
    }
547

548
    /**
549
     * Normalize an object to an array safe to iterate with foreach.
550
     *
551
     * Entities are converted via toArray() so internal properties like
552
     * `_options` or `_cast` are not exposed. Other Traversable objects are
553
     * converted to an array with their keys preserved; plain objects fall back
554
     * to their public properties.
555
     *
556
     * @return array<array-key, mixed>
557
     */
558
    private static function toIterable(object $data): array
559
    {
560
        $array = self::entityToArray($data);
4✔
561

562
        if ($array !== null) {
4✔
UNCOV
563
            return $array;
×
564
        }
565

566
        if ($data instanceof Traversable) {
4✔
567
            return iterator_to_array($data);
1✔
568
        }
569

570
        return get_object_vars($data);
3✔
571
    }
572

573
    /**
574
     * Whether an object should be expanded into an array when building output.
575
     *
576
     * Only enumerable containers are expanded: entities, `stdClass`, other
577
     * `Traversable` objects, and plain objects exposing public properties.
578
     * Opaque objects with no enumerable key surface (value-objects such as
579
     * `DateTimeImmutable`, magic-only or pure `ArrayAccess` objects) are
580
     * preserved as-is, since they cannot be faithfully rebuilt as an array.
581
     */
582
    private static function isExpandable(object $value): bool
583
    {
584
        return $value instanceof Entity
13✔
585
            || $value instanceof stdClass
13✔
586
            || $value instanceof Traversable
13✔
587
            || get_object_vars($value) !== [];
13✔
588
    }
589

590
    /**
591
     * Ensure a value can be descended into for a partial exclusion/projection.
592
     *
593
     * Arrays pass through; expandable objects are converted to an array view
594
     * in place (this is the only point where output structure is fabricated).
595
     * Anything else (scalars, value-objects, magic-only or pure `ArrayAccess`
596
     * objects) is left untouched and reported as non-descendable.
597
     */
598
    private static function expandForDescent(mixed &$value): bool
599
    {
600
        if (is_array($value)) {
16✔
601
            return true;
13✔
602
        }
603

604
        if (is_object($value) && self::isExpandable($value)) {
3✔
605
            $value = self::entries($value);
3✔
606

607
            return true;
3✔
608
        }
609

UNCOV
610
        return false;
×
611
    }
612

613
    /**
614
     * Throws exception for invalid wildcard patterns.
615
     */
616
    private static function ensureValidWildcardPattern(string $index, bool $allowTrailingWildcard = false): void
617
    {
618
        if ((! $allowTrailingWildcard && str_ends_with($index, '*')) || str_contains($index, '*.*')) {
184✔
619
            throw new InvalidArgumentException(
7✔
620
                'You must set key right after "*". Invalid index: "' . $index . '"',
7✔
621
            );
7✔
622
        }
623
    }
624

625
    /**
626
     * Set value recursively by dot path, including wildcard support.
627
     *
628
     * @param array<array-key, mixed> $array
629
     * @param list<string>            $indexes
630
     */
631
    private static function setByDotPath(array &$array, array $indexes, mixed $value): void
632
    {
633
        if ($indexes === []) {
59✔
UNCOV
634
            return;
×
635
        }
636

637
        $currentIndex = array_shift($indexes);
59✔
638

639
        if ($currentIndex === '*') {
59✔
640
            foreach ($array as &$item) {
3✔
641
                if (! is_array($item)) {
3✔
642
                    continue;
1✔
643
                }
644

645
                self::setByDotPath($item, $indexes, $value);
3✔
646
            }
647
            unset($item);
3✔
648

649
            return;
3✔
650
        }
651

652
        if ($indexes === []) {
59✔
653
            $array[$currentIndex] = $value;
59✔
654

655
            return;
59✔
656
        }
657

658
        if (! isset($array[$currentIndex]) || ! is_array($array[$currentIndex])) {
52✔
659
            $array[$currentIndex] = [];
46✔
660
        }
661

662
        self::setByDotPath($array[$currentIndex], $indexes, $value);
52✔
663
    }
664

665
    /**
666
     * Unset value recursively by dot path, including wildcard support.
667
     *
668
     * @param array<array-key, mixed> $array
669
     * @param list<string>            $indexes
670
     */
671
    private static function unsetByDotPath(array &$array, array $indexes): int
672
    {
673
        if ($indexes === []) {
13✔
UNCOV
674
            return 0;
×
675
        }
676

677
        $currentIndex = array_shift($indexes);
13✔
678

679
        if ($currentIndex === '*') {
13✔
680
            $removed = 0;
3✔
681

682
            foreach ($array as &$item) {
3✔
683
                if (! is_array($item)) {
3✔
684
                    continue;
×
685
                }
686

687
                $removed += self::unsetByDotPath($item, $indexes);
3✔
688
            }
689
            unset($item);
3✔
690

691
            return $removed;
3✔
692
        }
693

694
        if ($indexes === []) {
13✔
695
            if (! array_key_exists($currentIndex, $array)) {
12✔
696
                return 0;
1✔
697
            }
698

699
            unset($array[$currentIndex]);
11✔
700

701
            return 1;
11✔
702
        }
703

704
        if (! isset($array[$currentIndex]) || ! is_array($array[$currentIndex])) {
13✔
705
            return 0;
1✔
706
        }
707

708
        return self::unsetByDotPath($array[$currentIndex], $indexes);
13✔
709
    }
710

711
    /**
712
     * Clears all children under the specified path.
713
     *
714
     * @param array<array-key, mixed> $array
715
     * @param list<string>            $indexes
716
     */
717
    private static function clearByDotPath(array &$array, array $indexes): int
718
    {
719
        if ($indexes === []) {
3✔
720
            $count = count($array);
3✔
721
            $array = [];
3✔
722

723
            return $count;
3✔
724
        }
725

726
        $currentIndex = array_shift($indexes);
2✔
727

728
        if ($currentIndex === '*') {
2✔
UNCOV
729
            $cleared = 0;
×
730

UNCOV
731
            foreach ($array as &$item) {
×
UNCOV
732
                if (! is_array($item)) {
×
UNCOV
733
                    continue;
×
734
                }
735

UNCOV
736
                $cleared += self::clearByDotPath($item, $indexes);
×
737
            }
UNCOV
738
            unset($item);
×
739

UNCOV
740
            return $cleared;
×
741
        }
742

743
        if (! array_key_exists($currentIndex, $array) || ! is_array($array[$currentIndex])) {
2✔
UNCOV
744
            return 0;
×
745
        }
746

747
        return self::clearByDotPath($array[$currentIndex], $indexes);
2✔
748
    }
749

750
    /**
751
     * Removes a value by dot path for dotExcept(). Objects are expanded to an
752
     * array view only when the path descends into them, so untouched branches
753
     * keep their original values (including objects).
754
     *
755
     * @param array<array-key, mixed> $array
756
     * @param list<string>            $indexes
757
     */
758
    private static function excludeByDotPath(array &$array, array $indexes): int
759
    {
760
        if ($indexes === []) {
18✔
UNCOV
761
            return 0;
×
762
        }
763

764
        $currentIndex = array_shift($indexes);
18✔
765

766
        if ($currentIndex === '*') {
18✔
767
            $removed = 0;
1✔
768

769
            foreach ($array as &$item) {
1✔
770
                if (self::expandForDescent($item)) {
1✔
771
                    $removed += self::excludeByDotPath($item, $indexes);
1✔
772
                }
773
            }
774
            unset($item);
1✔
775

776
            return $removed;
1✔
777
        }
778

779
        if ($indexes === []) {
18✔
780
            if (! array_key_exists($currentIndex, $array)) {
18✔
781
                return 0;
5✔
782
            }
783

784
            unset($array[$currentIndex]);
17✔
785

786
            return 1;
17✔
787
        }
788

789
        if (! array_key_exists($currentIndex, $array) || ! self::expandForDescent($array[$currentIndex])) {
13✔
UNCOV
790
            return 0;
×
791
        }
792

793
        return self::excludeByDotPath($array[$currentIndex], $indexes);
13✔
794
    }
795

796
    /**
797
     * Clears all children under the specified path for dotExcept(), expanding
798
     * objects to an array view only along the descended path.
799
     *
800
     * @param array<array-key, mixed> $array
801
     * @param list<string>            $indexes
802
     */
803
    private static function excludeChildrenByDotPath(array &$array, array $indexes): int
804
    {
805
        if ($indexes === []) {
3✔
806
            $count = count($array);
3✔
807
            $array = [];
3✔
808

809
            return $count;
3✔
810
        }
811

812
        $currentIndex = array_shift($indexes);
3✔
813

814
        if ($currentIndex === '*') {
3✔
UNCOV
815
            $cleared = 0;
×
816

UNCOV
817
            foreach ($array as &$item) {
×
UNCOV
818
                if (self::expandForDescent($item)) {
×
UNCOV
819
                    $cleared += self::excludeChildrenByDotPath($item, $indexes);
×
820
                }
821
            }
UNCOV
822
            unset($item);
×
823

UNCOV
824
            return $cleared;
×
825
        }
826

827
        if (! array_key_exists($currentIndex, $array) || ! self::expandForDescent($array[$currentIndex])) {
3✔
UNCOV
828
            return 0;
×
829
        }
830

831
        return self::excludeChildrenByDotPath($array[$currentIndex], $indexes);
3✔
832
    }
833

834
    /**
835
     * Projects matching paths from source into result with preserved structure.
836
     *
837
     * @param array<array-key, mixed>|object $source
838
     * @param list<string>                   $indexes
839
     * @param list<string>                   $prefix
840
     * @param array<array-key, mixed>        $result
841
     */
842
    private static function projectByDotPath(
843
        array|object $source,
844
        array $indexes,
845
        array &$result,
846
        array $prefix = [],
847
    ): void {
848
        if ($indexes === []) {
21✔
849
            // The whole node was selected: preserve it as-is. Output structure
850
            // is only fabricated for the projection skeleton above this leaf.
851
            self::setByDotPath($result, $prefix, $source);
3✔
852

853
            return;
3✔
854
        }
855

856
        $currentIndex = array_shift($indexes);
21✔
857

858
        if ($currentIndex === '*') {
21✔
859
            foreach (self::entries($source) as $key => $value) {
2✔
860
                if (! self::isNavigable($value)) {
2✔
UNCOV
861
                    if ($indexes === []) {
×
UNCOV
862
                        self::setByDotPath($result, [...$prefix, (string) $key], $value);
×
863
                    }
864

UNCOV
865
                    continue;
×
866
                }
867

868
                self::projectByDotPath($value, $indexes, $result, [...$prefix, (string) $key]);
2✔
869
            }
870

871
            return;
2✔
872
        }
873

874
        [$found, $value] = self::resolve($source, $currentIndex);
21✔
875

876
        if (! $found) {
21✔
877
            return;
8✔
878
        }
879

880
        if (! self::isNavigable($value)) {
21✔
881
            if ($indexes === []) {
18✔
882
                self::setByDotPath($result, [...$prefix, $currentIndex], $value);
18✔
883
            }
884

885
            return;
18✔
886
        }
887

888
        self::projectByDotPath($value, $indexes, $result, [...$prefix, $currentIndex]);
17✔
889
    }
890
}
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