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

codeigniter4 / CodeIgniter4 / 26812134420

02 Jun 2026 09:51AM UTC coverage: 88.561% (+0.01%) from 88.547%
26812134420

Pull #10269

github

web-flow
Merge 879538100 into 12888fbec
Pull Request #10269: feat: support objects in remaining dot array helpers

27 of 30 new or added lines in 1 file covered. (90.0%)

1 existing line in 1 file now uncovered.

24310 of 27450 relevant lines covered (88.56%)

223.78 hits per line

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

91.09
/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,725✔
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,831✔
58

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

63
        // Fast path: no escaped dots, skip the regex entirely.
64
        if (! str_contains($trimmed, '\\.')) {
1,822✔
65
            return array_values(array_filter(
1,814✔
66
                explode('.', $trimmed),
1,814✔
67
                static fn ($s): bool => $s !== '',
1,814✔
68
            ));
1,814✔
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,725✔
94
            return null;
9✔
95
        }
96

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

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

104
        // Handle Wildcard (*)
105
        if ($currentIndex === '*') {
1,595✔
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,595✔
130
            return self::value($array, $currentIndex);
1,588✔
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>|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
            $iterable = is_object($array) ? self::toIterable($array) : $array;
16✔
180

181
            foreach ($iterable as $item) {
16✔
182
                if ((! is_array($item) && ! is_object($item)) || ! self::hasByDotPath($item, $indexes)) {
16✔
183
                    return false;
11✔
184
                }
185
            }
186

187
            return true;
9✔
188
        }
189

190
        if (! self::valueExists($array, $currentIndex)) {
118✔
191
            return false;
53✔
192
        }
193

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

198
        $value = self::value($array, $currentIndex);
64✔
199

200
        if (! is_array($value) && ! is_object($value)) {
64✔
UNCOV
201
            return false;
×
202
        }
203

204
        return self::hasByDotPath($value, $indexes);
64✔
205
    }
206

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

216
        $indexes = self::convertToArray($index);
42✔
217

218
        if ($indexes === []) {
42✔
219
            return;
×
220
        }
221

222
        self::setByDotPath($array, $indexes, $value);
42✔
223
    }
224

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

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

238
        $indexes = self::convertToArray($index);
15✔
239

240
        if ($indexes === []) {
15✔
241
            return false;
×
242
        }
243

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

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

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

264
        foreach ($indexes as $index) {
19✔
265
            self::ensureValidWildcardPattern($index, true);
19✔
266

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

270
                continue;
1✔
271
            }
272

273
            $segments = self::convertToArray($index);
18✔
274
            if ($segments === []) {
18✔
275
                continue;
×
276
            }
277

278
            self::projectByDotPath($array, $segments, $result);
18✔
279
        }
280

281
        return $result;
19✔
282
    }
283

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

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

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

303
                continue;
1✔
304
            }
305

306
            if (str_ends_with($index, '*')) {
17✔
307
                $segments = self::convertToArray($index);
3✔
308
                self::clearByDotPath($result, $segments);
3✔
309

310
                continue;
3✔
311
            }
312

313
            $segments = self::convertToArray($index);
14✔
314
            if ($segments !== []) {
14✔
315
                self::unsetByDotPath($result, $segments);
14✔
316
            }
317
        }
318

319
        return $result;
18✔
320
    }
321

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

339
        $result = [];
9✔
340

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

345
        return $result;
9✔
346
    }
347

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

366
            return $result;
9✔
367
        }
368

369
        $value = self::dotSearch($index, $row);
9✔
370

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

375
        if (is_bool($value)) {
9✔
376
            $value = (int) $value;
×
377
        }
378

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

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

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

389
        return $result;
9✔
390
    }
391

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

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

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

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

413
            if (is_array($originalValue)) {
5✔
414
                $diffArrays = [];
5✔
415

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

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

430
        return $difference;
5✔
431
    }
432

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

443
            $counter++;
8✔
444
        }
445

446
        return $counter;
8✔
447
    }
448

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

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

467
    /**
468
     * @param array<array-key, mixed>|object $data
469
     */
470
    private static function valueExists(array|object $data, string $key): bool
471
    {
472
        if (is_array($data)) {
1,790✔
473
            return array_key_exists($key, $data);
1,786✔
474
        }
475

476
        $array = self::entityToArray($data);
15✔
477

478
        if ($array !== null) {
15✔
479
            return array_key_exists($key, $array);
5✔
480
        }
481

482
        if ($data instanceof ArrayAccess && $data->offsetExists($key)) {
10✔
483
            return true;
2✔
484
        }
485

486
        if (array_key_exists($key, get_object_vars($data))) {
8✔
487
            return true;
6✔
488
        }
489

490
        return isset($data->{$key});
3✔
491
    }
492

493
    /**
494
     * @param array<array-key, mixed>|object $data
495
     */
496
    private static function value(array|object $data, string $key): mixed
497
    {
498
        if (is_array($data)) {
1,653✔
499
            return $data[$key];
1,649✔
500
        }
501

502
        $array = self::entityToArray($data);
13✔
503

504
        if ($array !== null) {
13✔
505
            return $array[$key];
3✔
506
        }
507

508
        if ($data instanceof ArrayAccess && $data->offsetExists($key)) {
10✔
509
            return $data->offsetGet($key);
2✔
510
        }
511

512
        $properties = get_object_vars($data);
8✔
513

514
        if (array_key_exists($key, $properties)) {
8✔
515
            return $properties[$key];
6✔
516
        }
517

518
        return $data->{$key};
2✔
519
    }
520

521
    /**
522
     * @return array<array-key, mixed>|null
523
     */
524
    private static function entityToArray(object $data): ?array
525
    {
526
        if ($data instanceof Entity) {
17✔
527
            return $data->toArray();
5✔
528
        }
529

530
        return null;
12✔
531
    }
532

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

546
        if ($array !== null) {
2✔
547
            return $array;
×
548
        }
549

550
        if ($data instanceof Traversable) {
2✔
551
            return iterator_to_array($data, false);
×
552
        }
553

554
        return get_object_vars($data);
2✔
555
    }
556

557
    /**
558
     * Normalize arrays or objects to an array view safe for dotExcept().
559
     *
560
     * @param array<array-key, mixed>|object $data
561
     *
562
     * @return array<array-key, mixed>
563
     */
564
    private static function toArrayView(array|object $data): array
565
    {
566
        $array = is_object($data) ? self::toIterable($data) : $data;
18✔
567

568
        foreach ($array as $key => $value) {
18✔
569
            if (is_array($value) || is_object($value)) {
18✔
570
                $array[$key] = self::toArrayView($value);
14✔
571
            }
572
        }
573

574
        return $array;
18✔
575
    }
576

577
    /**
578
     * Throws exception for invalid wildcard patterns.
579
     */
580
    private static function ensureValidWildcardPattern(string $index, bool $allowTrailingWildcard = false): void
581
    {
582
        if ((! $allowTrailingWildcard && str_ends_with($index, '*')) || str_contains($index, '*.*')) {
178✔
583
            throw new InvalidArgumentException(
7✔
584
                'You must set key right after "*". Invalid index: "' . $index . '"',
7✔
585
            );
7✔
586
        }
587
    }
588

589
    /**
590
     * Set value recursively by dot path, including wildcard support.
591
     *
592
     * @param array<array-key, mixed> $array
593
     * @param list<string>            $indexes
594
     */
595
    private static function setByDotPath(array &$array, array $indexes, mixed $value): void
596
    {
597
        if ($indexes === []) {
56✔
598
            return;
×
599
        }
600

601
        $currentIndex = array_shift($indexes);
56✔
602

603
        if ($currentIndex === '*') {
56✔
604
            foreach ($array as &$item) {
3✔
605
                if (! is_array($item)) {
3✔
606
                    continue;
1✔
607
                }
608

609
                self::setByDotPath($item, $indexes, $value);
3✔
610
            }
611
            unset($item);
3✔
612

613
            return;
3✔
614
        }
615

616
        if ($indexes === []) {
56✔
617
            $array[$currentIndex] = $value;
56✔
618

619
            return;
56✔
620
        }
621

622
        if (! isset($array[$currentIndex]) || ! is_array($array[$currentIndex])) {
50✔
623
            $array[$currentIndex] = [];
44✔
624
        }
625

626
        self::setByDotPath($array[$currentIndex], $indexes, $value);
50✔
627
    }
628

629
    /**
630
     * Unset value recursively by dot path, including wildcard support.
631
     *
632
     * @param array<array-key, mixed> $array
633
     * @param list<string>            $indexes
634
     */
635
    private static function unsetByDotPath(array &$array, array $indexes): int
636
    {
637
        if ($indexes === []) {
27✔
638
            return 0;
×
639
        }
640

641
        $currentIndex = array_shift($indexes);
27✔
642

643
        if ($currentIndex === '*') {
27✔
644
            $removed = 0;
4✔
645

646
            foreach ($array as &$item) {
4✔
647
                if (! is_array($item)) {
4✔
648
                    continue;
×
649
                }
650

651
                $removed += self::unsetByDotPath($item, $indexes);
4✔
652
            }
653
            unset($item);
4✔
654

655
            return $removed;
4✔
656
        }
657

658
        if ($indexes === []) {
27✔
659
            if (! array_key_exists($currentIndex, $array)) {
26✔
660
                return 0;
5✔
661
            }
662

663
            unset($array[$currentIndex]);
25✔
664

665
            return 1;
25✔
666
        }
667

668
        if (! isset($array[$currentIndex]) || ! is_array($array[$currentIndex])) {
23✔
669
            return 0;
1✔
670
        }
671

672
        return self::unsetByDotPath($array[$currentIndex], $indexes);
23✔
673
    }
674

675
    /**
676
     * Clears all children under the specified path.
677
     *
678
     * @param array<array-key, mixed> $array
679
     * @param list<string>            $indexes
680
     */
681
    private static function clearByDotPath(array &$array, array $indexes): int
682
    {
683
        if ($indexes === []) {
6✔
684
            $count = count($array);
6✔
685
            $array = [];
6✔
686

687
            return $count;
6✔
688
        }
689

690
        $currentIndex = array_shift($indexes);
5✔
691

692
        if ($currentIndex === '*') {
5✔
693
            $cleared = 0;
×
694

695
            foreach ($array as &$item) {
×
696
                if (! is_array($item)) {
×
697
                    continue;
×
698
                }
699

700
                $cleared += self::clearByDotPath($item, $indexes);
×
701
            }
702
            unset($item);
×
703

704
            return $cleared;
×
705
        }
706

707
        if (! array_key_exists($currentIndex, $array) || ! is_array($array[$currentIndex])) {
5✔
708
            return 0;
×
709
        }
710

711
        return self::clearByDotPath($array[$currentIndex], $indexes);
5✔
712
    }
713

714
    /**
715
     * Projects matching paths from source into result with preserved structure.
716
     *
717
     * @param array<array-key, mixed>|object $source
718
     * @param list<string>                   $indexes
719
     * @param list<string>                   $prefix
720
     * @param array<array-key, mixed>        $result
721
     */
722
    private static function projectByDotPath(
723
        array|object $source,
724
        array $indexes,
725
        array &$result,
726
        array $prefix = [],
727
    ): void {
728
        if ($indexes === []) {
18✔
729
            self::setByDotPath($result, $prefix, $source);
2✔
730

731
            return;
2✔
732
        }
733

734
        $currentIndex = array_shift($indexes);
18✔
735

736
        if ($currentIndex === '*') {
18✔
737
            $iterable = is_object($source) ? self::toIterable($source) : $source;
2✔
738

739
            foreach ($iterable as $key => $value) {
2✔
740
                if (! is_array($value) && ! is_object($value)) {
2✔
NEW
741
                    if ($indexes === []) {
×
NEW
742
                        self::setByDotPath($result, [...$prefix, (string) $key], $value);
×
743
                    }
744

NEW
745
                    continue;
×
746
                }
747

748
                self::projectByDotPath($value, $indexes, $result, [...$prefix, (string) $key]);
2✔
749
            }
750

751
            return;
2✔
752
        }
753

754
        if (! self::valueExists($source, $currentIndex)) {
18✔
755
            return;
8✔
756
        }
757

758
        $value = self::value($source, $currentIndex);
18✔
759

760
        if (! is_array($value) && ! is_object($value)) {
18✔
761
            if ($indexes === []) {
16✔
762
                self::setByDotPath($result, [...$prefix, $currentIndex], $value);
16✔
763
            }
764

765
            return;
16✔
766
        }
767

768
        self::projectByDotPath($value, $indexes, $result, [...$prefix, $currentIndex]);
14✔
769
    }
770
}
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