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

codeigniter4 / CodeIgniter4 / 25446004441

06 May 2026 03:51PM UTC coverage: 88.296% (+0.02%) from 88.274%
25446004441

Pull #10158

github

web-flow
Merge c8dc6f1b5 into 3f912495d
Pull Request #10158: feat: add typed FormRequest accessors

89 of 98 new or added lines in 5 files covered. (90.82%)

63 existing lines in 2 files now uncovered.

23613 of 26743 relevant lines covered (88.3%)

217.9 hits per line

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

90.86
/system/Database/BaseConnection.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\Database;
15

16
use Closure;
17
use CodeIgniter\Database\Exceptions\DatabaseException;
18
use CodeIgniter\Events\Events;
19
use CodeIgniter\I18n\Time;
20
use Exception;
21
use ReflectionClass;
22
use ReflectionNamedType;
23
use ReflectionType;
24
use ReflectionUnionType;
25
use stdClass;
26
use Stringable;
27
use Throwable;
28

29
/**
30
 * @property-read array      $aliasedTables
31
 * @property-read string     $charset
32
 * @property-read bool       $compress
33
 * @property-read float      $connectDuration
34
 * @property-read float      $connectTime
35
 * @property-read string     $database
36
 * @property-read array      $dateFormat
37
 * @property-read string     $DBCollat
38
 * @property-read bool       $DBDebug
39
 * @property-read string     $DBDriver
40
 * @property-read string     $DBPrefix
41
 * @property-read string     $DSN
42
 * @property-read array|bool $encrypt
43
 * @property-read array      $failover
44
 * @property-read string     $hostname
45
 * @property-read Query      $lastQuery
46
 * @property-read string     $password
47
 * @property-read bool       $pConnect
48
 * @property-read int|string $port
49
 * @property-read bool       $pretend
50
 * @property-read string     $queryClass
51
 * @property-read array      $reservedIdentifiers
52
 * @property-read string     $subdriver
53
 * @property-read string     $swapPre
54
 * @property-read int        $transDepth
55
 * @property-read bool       $transFailure
56
 * @property-read bool       $transStatus
57
 * @property-read string     $username
58
 *
59
 * @template TConnection
60
 * @template TResult
61
 *
62
 * @implements ConnectionInterface<TConnection, TResult>
63
 * @see \CodeIgniter\Database\BaseConnectionTest
64
 */
65
abstract class BaseConnection implements ConnectionInterface
66
{
67
    /**
68
     * Cached builtin type names per class/property.
69
     *
70
     * @var array<class-string, array<string, list<string>>>
71
     */
72
    private static array $propertyBuiltinTypesCache = [];
73

74
    /**
75
     * Data Source Name / Connect string
76
     *
77
     * @var string
78
     */
79
    protected $DSN;
80

81
    /**
82
     * Database port
83
     *
84
     * @var int|string
85
     */
86
    protected $port = '';
87

88
    /**
89
     * Hostname
90
     *
91
     * @var string
92
     */
93
    protected $hostname;
94

95
    /**
96
     * Username
97
     *
98
     * @var string
99
     */
100
    protected $username;
101

102
    /**
103
     * Password
104
     *
105
     * @var string
106
     */
107
    protected $password;
108

109
    /**
110
     * Database name
111
     *
112
     * @var string
113
     */
114
    protected $database;
115

116
    /**
117
     * Database driver
118
     *
119
     * @var string
120
     */
121
    protected $DBDriver = 'MySQLi';
122

123
    /**
124
     * Sub-driver
125
     *
126
     * @used-by CI_DB_pdo_driver
127
     *
128
     * @var string
129
     */
130
    protected $subdriver;
131

132
    /**
133
     * Table prefix
134
     *
135
     * @var string
136
     */
137
    protected $DBPrefix = '';
138

139
    /**
140
     * Persistent connection flag
141
     *
142
     * @var bool
143
     */
144
    protected $pConnect = false;
145

146
    /**
147
     * Whether to throw Exception or not when an error occurs.
148
     *
149
     * @var bool
150
     */
151
    protected $DBDebug = true;
152

153
    /**
154
     * Character set
155
     *
156
     * This value must be updated by Config\Database if the driver use it.
157
     *
158
     * @var string
159
     */
160
    protected $charset = '';
161

162
    /**
163
     * Collation
164
     *
165
     * This value must be updated by Config\Database if the driver use it.
166
     *
167
     * @var string
168
     */
169
    protected $DBCollat = '';
170

171
    /**
172
     * Database session timezone
173
     *
174
     * false    = Don't set timezone (default, backward compatible)
175
     * true     = Automatically sync with app timezone
176
     * string   = Specific timezone (offset or named timezone)
177
     *
178
     * Named timezones (e.g., 'America/New_York') will be automatically
179
     * converted to offsets (e.g., '-05:00') for database compatibility.
180
     *
181
     * @var bool|string
182
     */
183
    protected $timezone = false;
184

185
    /**
186
     * Swap Prefix
187
     *
188
     * @var string
189
     */
190
    protected $swapPre = '';
191

192
    /**
193
     * Encryption flag/data
194
     *
195
     * @var array|bool
196
     */
197
    protected $encrypt = false;
198

199
    /**
200
     * Compression flag
201
     *
202
     * @var bool
203
     */
204
    protected $compress = false;
205

206
    /**
207
     * Settings for a failover connection.
208
     *
209
     * @var array
210
     */
211
    protected $failover = [];
212

213
    /**
214
     * The last query object that was executed
215
     * on this connection.
216
     *
217
     * @var Query
218
     */
219
    protected $lastQuery;
220

221
    /**
222
     * The exception that would have been thrown on the last failed query
223
     * if DBDebug were enabled. Null when the last query succeeded or when
224
     * DBDebug is true (in which case the exception is thrown directly and
225
     * this property is never set).
226
     */
227
    protected ?DatabaseException $lastException = null;
228

229
    /**
230
     * Connection ID
231
     *
232
     * @var false|TConnection
233
     */
234
    public $connID = false;
235

236
    /**
237
     * Result ID
238
     *
239
     * @var false|TResult
240
     */
241
    public $resultID = false;
242

243
    /**
244
     * Protect identifiers flag
245
     *
246
     * @var bool
247
     */
248
    public $protectIdentifiers = true;
249

250
    /**
251
     * List of reserved identifiers
252
     *
253
     * Identifiers that must NOT be escaped.
254
     *
255
     * @var array
256
     */
257
    protected $reservedIdentifiers = ['*'];
258

259
    /**
260
     * Identifier escape character
261
     *
262
     * @var array|string
263
     */
264
    public $escapeChar = '"';
265

266
    /**
267
     * ESCAPE statement string
268
     *
269
     * @var string
270
     */
271
    public $likeEscapeStr = " ESCAPE '%s' ";
272

273
    /**
274
     * ESCAPE character
275
     *
276
     * @var string
277
     */
278
    public $likeEscapeChar = '!';
279

280
    /**
281
     * RegExp used to escape identifiers
282
     *
283
     * @var array
284
     */
285
    protected $pregEscapeChar = [];
286

287
    /**
288
     * Holds previously looked up data
289
     * for performance reasons.
290
     *
291
     * @var array
292
     */
293
    public $dataCache = [];
294

295
    /**
296
     * Microtime when connection was made
297
     *
298
     * @var float
299
     */
300
    protected $connectTime = 0.0;
301

302
    /**
303
     * How long it took to establish connection.
304
     *
305
     * @var float
306
     */
307
    protected $connectDuration = 0.0;
308

309
    /**
310
     * If true, no queries will actually be
311
     * run against the database.
312
     *
313
     * @var bool
314
     */
315
    protected $pretend = false;
316

317
    /**
318
     * Transaction enabled flag
319
     *
320
     * @var bool
321
     */
322
    public $transEnabled = true;
323

324
    /**
325
     * Strict transaction mode flag
326
     *
327
     * @var bool
328
     */
329
    public $transStrict = true;
330

331
    /**
332
     * Transaction depth level
333
     *
334
     * @var int
335
     */
336
    protected $transDepth = 0;
337

338
    /**
339
     * Transaction status flag
340
     *
341
     * Used with transactions to determine if a rollback should occur.
342
     *
343
     * @var bool
344
     */
345
    protected $transStatus = true;
346

347
    /**
348
     * Transaction failure flag
349
     *
350
     * Used with transactions to determine if a transaction has failed.
351
     *
352
     * @var bool
353
     */
354
    protected $transFailure = false;
355

356
    /**
357
     * Whether to throw exceptions during transaction
358
     */
359
    protected bool $transException = false;
360

361
    /**
362
     * Callbacks to run after the outermost transaction commits.
363
     *
364
     * @var list<callable(): void>
365
     */
366
    protected array $transCommitCallbacks = [];
367

368
    /**
369
     * Callbacks to run after the outermost transaction rolls back.
370
     *
371
     * @var list<callable(): void>
372
     */
373
    protected array $transRollbackCallbacks = [];
374

375
    /**
376
     * Array of table aliases.
377
     *
378
     * @var list<string>
379
     */
380
    protected $aliasedTables = [];
381

382
    /**
383
     * Query Class
384
     *
385
     * @var string
386
     */
387
    protected $queryClass = Query::class;
388

389
    /**
390
     * Default Date/Time formats
391
     *
392
     * @var array<string, string>
393
     */
394
    protected array $dateFormat = [
395
        'date'        => 'Y-m-d',
396
        'datetime'    => 'Y-m-d H:i:s',
397
        'datetime-ms' => 'Y-m-d H:i:s.v',
398
        'datetime-us' => 'Y-m-d H:i:s.u',
399
        'time'        => 'H:i:s',
400
    ];
401

402
    /**
403
     * Saves our connection settings.
404
     */
405
    public function __construct(array $params)
406
    {
407
        if (isset($params['dateFormat'])) {
481✔
408
            $this->dateFormat = array_merge($this->dateFormat, $params['dateFormat']);
155✔
409
            unset($params['dateFormat']);
155✔
410
        }
411

412
        $typedPropertyTypes = $this->getBuiltinPropertyTypesMap(array_keys($params));
481✔
413

414
        foreach ($params as $key => $value) {
481✔
415
            if (property_exists($this, $key)) {
187✔
416
                $this->{$key} = $this->castScalarValueForTypedProperty(
187✔
417
                    $value,
187✔
418
                    $typedPropertyTypes[$key] ?? [],
187✔
419
                );
187✔
420
            }
421
        }
422

423
        $queryClass = str_replace('Connection', 'Query', static::class);
480✔
424

425
        if (class_exists($queryClass)) {
480✔
426
            $this->queryClass = $queryClass;
370✔
427
        }
428

429
        if ($this->failover !== []) {
480✔
430
            // If there is a failover database, connect now to do failover.
431
            // Otherwise, Query Builder creates SQL statement with the main database config
432
            // (DBPrefix) even when the main database is down.
433
            $this->initialize();
2✔
434
        }
435
    }
436

437
    /**
438
     * Some config values (especially env overrides without clear source type)
439
     * can still reach us as strings. Coerce them for typed properties to keep
440
     * strict typing compatible.
441
     *
442
     * @param list<string> $types
443
     */
444
    private function castScalarValueForTypedProperty(mixed $value, array $types): mixed
445
    {
446
        if (! is_string($value)) {
187✔
447
            return $value;
167✔
448
        }
449

450
        if ($types === [] || in_array('string', $types, true) || in_array('mixed', $types, true)) {
187✔
451
            return $value;
187✔
452
        }
453

454
        $trimmedValue = trim($value);
5✔
455

456
        if (in_array('null', $types, true) && strtolower($trimmedValue) === 'null') {
5✔
457
            return null;
1✔
458
        }
459

460
        if (in_array('int', $types, true) && preg_match('/^[+-]?\d+$/', $trimmedValue) === 1) {
5✔
461
            return (int) $trimmedValue;
2✔
462
        }
463

464
        if (in_array('float', $types, true) && is_numeric($trimmedValue)) {
4✔
465
            return (float) $trimmedValue;
×
466
        }
467

468
        if (in_array('bool', $types, true) || in_array('false', $types, true) || in_array('true', $types, true)) {
4✔
469
            $boolValue = filter_var($trimmedValue, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
3✔
470

471
            if ($boolValue !== null) {
3✔
472
                if (in_array('bool', $types, true)) {
3✔
473
                    return $boolValue;
2✔
474
                }
475

476
                if ($boolValue === false && in_array('false', $types, true)) {
1✔
477
                    return false;
1✔
478
                }
479

480
                if ($boolValue === true && in_array('true', $types, true)) {
1✔
481
                    return true;
1✔
482
                }
483
            }
484
        }
485

486
        return $value;
1✔
487
    }
488

489
    /**
490
     * @param list<string> $properties
491
     *
492
     * @return array<string, list<string>>
493
     */
494
    private function getBuiltinPropertyTypesMap(array $properties): array
495
    {
496
        $className = static::class;
481✔
497
        $requested = array_fill_keys($properties, true);
481✔
498

499
        if (! isset(self::$propertyBuiltinTypesCache[$className])) {
481✔
500
            self::$propertyBuiltinTypesCache[$className] = [];
28✔
501
        }
502

503
        // Fill only the properties requested by this call that are not cached yet.
504
        $missing = array_diff_key($requested, self::$propertyBuiltinTypesCache[$className]);
481✔
505

506
        if ($missing !== []) {
481✔
507
            $reflection = new ReflectionClass($className);
31✔
508

509
            foreach ($reflection->getProperties() as $property) {
31✔
510
                $propertyName = $property->getName();
31✔
511

512
                if (! isset($missing[$propertyName])) {
31✔
513
                    continue;
31✔
514
                }
515

516
                $type = $property->getType();
31✔
517

518
                if (! $type instanceof ReflectionType) {
31✔
519
                    self::$propertyBuiltinTypesCache[$className][$propertyName] = [];
30✔
520

521
                    continue;
30✔
522
                }
523

524
                $namedTypes   = $type instanceof ReflectionUnionType ? $type->getTypes() : [$type];
18✔
525
                $builtinTypes = [];
18✔
526

527
                foreach ($namedTypes as $namedType) {
18✔
528
                    if (! $namedType instanceof ReflectionNamedType || ! $namedType->isBuiltin()) {
18✔
529
                        continue;
×
530
                    }
531

532
                    $builtinTypes[] = $namedType->getName();
18✔
533
                }
534

535
                if ($type->allowsNull() && ! in_array('null', $builtinTypes, true)) {
18✔
536
                    $builtinTypes[] = 'null';
13✔
537
                }
538

539
                self::$propertyBuiltinTypesCache[$className][$propertyName] = $builtinTypes;
18✔
540
            }
541

542
            // Untyped or unresolved properties are cached as empty to avoid re-reflecting them.
543
            foreach (array_keys($missing) as $propertyName) {
31✔
544
                self::$propertyBuiltinTypesCache[$className][$propertyName] ??= [];
31✔
545
            }
546
        }
547

548
        $typedProperties = [];
481✔
549

550
        foreach ($properties as $property) {
481✔
551
            $typedProperties[$property] = self::$propertyBuiltinTypesCache[$className][$property] ?? [];
187✔
552
        }
553

554
        return $typedProperties;
481✔
555
    }
556

557
    /**
558
     * Initializes the database connection/settings.
559
     *
560
     * @return void
561
     *
562
     * @throws DatabaseException
563
     */
564
    public function initialize()
565
    {
566
        /* If an established connection is available, then there's
567
         * no need to connect and select the database.
568
         *
569
         * Depending on the database driver, connID can be either
570
         * boolean TRUE, a resource or an object.
571
         */
572
        if ($this->connID) {
883✔
573
            return;
814✔
574
        }
575

576
        $this->connectTime = microtime(true);
85✔
577
        $connectionErrors  = [];
85✔
578

579
        try {
580
            // Connect to the database and set the connection ID
581
            $this->connID = $this->connect($this->pConnect);
85✔
582
        } catch (Throwable $e) {
2✔
583
            $this->connID       = false;
2✔
584
            $connectionErrors[] = sprintf(
2✔
585
                'Main connection [%s]: %s',
2✔
586
                $this->DBDriver,
2✔
587
                $e->getMessage(),
2✔
588
            );
2✔
589
            log_message('error', 'Error connecting to the database: ' . $e);
2✔
590
        }
591

592
        // No connection resource? Check if there is a failover else throw an error
593
        if (! $this->connID) {
85✔
594
            // Check if there is a failover set
595
            if (! empty($this->failover) && is_array($this->failover)) {
4✔
596
                // Go over all the failovers
597
                foreach ($this->failover as $index => $failover) {
2✔
598
                    $typedPropertyTypes = $this->getBuiltinPropertyTypesMap(array_keys($failover));
2✔
599

600
                    // Replace the current settings with those of the failover
601
                    foreach ($failover as $key => $val) {
2✔
602
                        if (property_exists($this, $key)) {
2✔
603
                            $this->{$key} = $this->castScalarValueForTypedProperty(
2✔
604
                                $val,
2✔
605
                                $typedPropertyTypes[$key] ?? [],
2✔
606
                            );
2✔
607
                        }
608
                    }
609

610
                    try {
611
                        // Try to connect
612
                        $this->connID = $this->connect($this->pConnect);
2✔
613
                    } catch (Throwable $e) {
1✔
614
                        $connectionErrors[] = sprintf(
1✔
615
                            'Failover #%d [%s]: %s',
1✔
616
                            ++$index,
1✔
617
                            $this->DBDriver,
1✔
618
                            $e->getMessage(),
1✔
619
                        );
1✔
620
                        log_message('error', 'Error connecting to the database: ' . $e);
1✔
621
                    }
622

623
                    // If a connection is made break the foreach loop
624
                    if ($this->connID) {
2✔
625
                        break;
2✔
626
                    }
627
                }
628
            }
629

630
            // We still don't have a connection?
631
            if (! $this->connID) {
4✔
632
                throw new DatabaseException(sprintf(
2✔
633
                    'Unable to connect to the database.%s%s',
2✔
634
                    PHP_EOL,
2✔
635
                    implode(PHP_EOL, $connectionErrors),
2✔
636
                ));
2✔
637
            }
638
        }
639

640
        $this->connectDuration = microtime(true) - $this->connectTime;
83✔
641
    }
642

643
    /**
644
     * Close the database connection.
645
     *
646
     * @return void
647
     */
648
    public function close()
649
    {
650
        if ($this->connID) {
3✔
651
            $this->_close();
3✔
652
            $this->connID = false;
3✔
653
        }
654
    }
655

656
    /**
657
     * Keep or establish the connection if no queries have been sent for
658
     * a length of time exceeding the server's idle timeout.
659
     *
660
     * @return void
661
     */
662
    public function reconnect()
663
    {
664
        if ($this->ping() === false) {
2✔
665
            $this->close();
1✔
666
            $this->initialize();
1✔
667
        }
668
    }
669

670
    /**
671
     * Platform dependent way method for closing the connection.
672
     *
673
     * @return void
674
     */
675
    abstract protected function _close();
676

677
    /**
678
     * Check if the connection is still alive.
679
     */
680
    public function ping(): bool
681
    {
682
        if ($this->connID === false) {
5✔
683
            return false;
2✔
684
        }
685

686
        return $this->_ping();
4✔
687
    }
688

689
    /**
690
     * Driver-specific ping implementation.
691
     */
692
    protected function _ping(): bool
693
    {
694
        try {
695
            $result = $this->simpleQuery('SELECT 1');
4✔
696

697
            return $result !== false;
4✔
698
        } catch (DatabaseException) {
×
699
            return false;
×
700
        }
701
    }
702

703
    /**
704
     * Create a persistent database connection.
705
     *
706
     * @return false|TConnection
707
     */
708
    public function persistentConnect()
709
    {
710
        return $this->connect(true);
×
711
    }
712

713
    /**
714
     * Returns the actual connection object. If both a 'read' and 'write'
715
     * connection has been specified, you can pass either term in to
716
     * get that connection. If you pass either alias in and only a single
717
     * connection is present, it must return the sole connection.
718
     *
719
     * @return false|TConnection
720
     */
721
    public function getConnection(?string $alias = null)
722
    {
723
        // @todo work with read/write connections
724
        return $this->connID;
2✔
725
    }
726

727
    /**
728
     * Returns the name of the current database being used.
729
     */
730
    public function getDatabase(): string
731
    {
732
        return empty($this->database) ? '' : $this->database;
821✔
733
    }
734

735
    /**
736
     * Set DB Prefix
737
     *
738
     * Set's the DB Prefix to something new without needing to reconnect
739
     *
740
     * @param string $prefix The prefix
741
     */
742
    public function setPrefix(string $prefix = ''): string
743
    {
744
        return $this->DBPrefix = $prefix;
13✔
745
    }
746

747
    /**
748
     * Returns the database prefix.
749
     */
750
    public function getPrefix(): string
751
    {
752
        return $this->DBPrefix;
12✔
753
    }
754

755
    /**
756
     * The name of the platform in use (MySQLi, Postgre, SQLite3, OCI8, etc)
757
     */
758
    public function getPlatform(): string
759
    {
760
        return $this->DBDriver;
23✔
761
    }
762

763
    /**
764
     * Sets the Table Aliases to use. These are typically
765
     * collected during use of the Builder, and set here
766
     * so queries are built correctly.
767
     *
768
     * @return $this
769
     */
770
    public function setAliasedTables(array $aliases)
771
    {
772
        $this->aliasedTables = $aliases;
1,044✔
773

774
        return $this;
1,044✔
775
    }
776

777
    /**
778
     * Add a table alias to our list.
779
     *
780
     * @return $this
781
     */
782
    public function addTableAlias(string $alias)
783
    {
784
        if ($alias === '') {
30✔
785
            return $this;
6✔
786
        }
787

788
        if (! in_array($alias, $this->aliasedTables, true)) {
24✔
789
            $this->aliasedTables[] = $alias;
24✔
790
        }
791

792
        return $this;
24✔
793
    }
794

795
    /**
796
     * Executes the query against the database.
797
     *
798
     * @return false|TResult
799
     */
800
    abstract protected function execute(string $sql);
801

802
    /**
803
     * Orchestrates a query against the database. Queries must use
804
     * Database\Statement objects to store the query and build it.
805
     * This method works with the cache.
806
     *
807
     * Should automatically handle different connections for read/write
808
     * queries if needed.
809
     *
810
     * @param array|string|null $binds
811
     *
812
     * @return BaseResult<TConnection, TResult>|bool|Query
813
     *
814
     * @todo BC set $queryClass default as null in 4.1
815
     */
816
    public function query(string $sql, $binds = null, bool $setEscapeFlags = true, string $queryClass = '')
817
    {
818
        $queryClass = $queryClass !== '' && $queryClass !== '0' ? $queryClass : $this->queryClass;
876✔
819

820
        if (empty($this->connID)) {
876✔
821
            $this->initialize();
53✔
822
        }
823

824
        /** @var Query $query */
825
        $query = new $queryClass($this);
876✔
826

827
        $query->setQuery($sql, $binds, $setEscapeFlags);
876✔
828

829
        if (! empty($this->swapPre) && ! empty($this->DBPrefix)) {
876✔
830
            $query->swapPrefix($this->DBPrefix, $this->swapPre);
×
831
        }
832

833
        $startTime = microtime(true);
876✔
834

835
        // Always save the last query so we can use
836
        // the getLastQuery() method.
837
        $this->lastQuery = $query;
876✔
838

839
        // If $pretend is true, then we just want to return
840
        // the actual query object here. There won't be
841
        // any results to return.
842
        if ($this->pretend) {
876✔
843
            $query->setDuration($startTime);
10✔
844

845
            return $query;
10✔
846
        }
847

848
        // Run the query for real
849
        try {
850
            $exception           = null;
876✔
851
            $this->lastException = null;
876✔
852
            $this->resultID      = $this->simpleQuery($query->getQuery());
876✔
853
        } catch (DatabaseException $exception) {
18✔
854
            $this->resultID = false;
18✔
855
        }
856

857
        if ($this->resultID === false) {
876✔
858
            $query->setDuration($startTime, $startTime);
41✔
859

860
            // This will trigger a rollback if transactions are being used
861
            $this->handleTransStatus();
41✔
862

863
            if (
864
                $this->DBDebug
41✔
865
                && (
866
                    // Not in transactions
867
                    $this->transDepth === 0
41✔
868
                    // In transactions, do not throw exception by default.
41✔
869
                    || $this->transException
41✔
870
                )
871
            ) {
872
                // We call this function in order to roll-back queries
873
                // if transactions are enabled. If we don't call this here
874
                // the error message will trigger an exit, causing the
875
                // transactions to remain in limbo.
876
                while ($this->transDepth !== 0) {
14✔
877
                    $transDepth = $this->transDepth;
3✔
878
                    $this->transComplete();
3✔
879

880
                    if ($transDepth === $this->transDepth) {
3✔
881
                        log_message('error', 'Database: Failure during an automated transaction commit/rollback!');
×
882
                        break;
×
883
                    }
884
                }
885

886
                // Let others do something with this query.
887
                Events::trigger('DBQuery', $query);
14✔
888

889
                if ($exception instanceof DatabaseException) {
14✔
890
                    throw $exception;
12✔
891
                }
892

893
                return false;
2✔
894
            }
895

896
            // Let others do something with this query.
897
            Events::trigger('DBQuery', $query);
27✔
898

899
            return false;
27✔
900
        }
901

902
        $query->setDuration($startTime);
876✔
903

904
        // Let others do something with this query
905
        Events::trigger('DBQuery', $query);
876✔
906

907
        // resultID is not false, so it must be successful
908
        if ($this->isWriteType($sql)) {
876✔
909
            return true;
837✔
910
        }
911

912
        // query is not write-type, so it must be read-type query; return QueryResult
913
        $resultClass = str_replace('Connection', 'Result', static::class);
875✔
914

915
        return new $resultClass($this->connID, $this->resultID);
875✔
916
    }
917

918
    /**
919
     * Performs a basic query against the database. No binding or caching
920
     * is performed, nor are transactions handled. Simply takes a raw
921
     * query string and returns the database-specific result id.
922
     *
923
     * @return false|TResult
924
     */
925
    public function simpleQuery(string $sql)
926
    {
927
        if (empty($this->connID)) {
883✔
928
            $this->initialize();
6✔
929
        }
930

931
        return $this->execute($sql);
883✔
932
    }
933

934
    /**
935
     * Disable Transactions
936
     *
937
     * This permits transactions to be disabled at run-time.
938
     *
939
     * @return void
940
     */
941
    public function transOff()
942
    {
943
        $this->transEnabled = false;
2✔
944
    }
945

946
    /**
947
     * Enable/disable Transaction Strict Mode
948
     *
949
     * When strict mode is enabled, if you are running multiple groups of
950
     * transactions, if one group fails all subsequent groups will be
951
     * rolled back.
952
     *
953
     * If strict mode is disabled, each group is treated autonomously,
954
     * meaning a failure of one group will not affect any others
955
     *
956
     * @param bool $mode = true
957
     *
958
     * @return $this
959
     */
960
    public function transStrict(bool $mode = true)
961
    {
962
        $this->transStrict = $mode;
6✔
963

964
        return $this;
6✔
965
    }
966

967
    /**
968
     * Start Transaction
969
     */
970
    public function transStart(bool $testMode = false): bool
971
    {
972
        if (! $this->transEnabled) {
63✔
973
            return false;
×
974
        }
975

976
        return $this->transBegin($testMode);
63✔
977
    }
978

979
    /**
980
     * If set to true, exceptions are thrown during transactions.
981
     *
982
     * @return $this
983
     */
984
    public function transException(bool $transException)
985
    {
986
        $this->transException = $transException;
4✔
987

988
        return $this;
4✔
989
    }
990

991
    /**
992
     * Complete Transaction
993
     */
994
    public function transComplete(): bool
995
    {
996
        if (! $this->transEnabled) {
68✔
997
            return false;
×
998
        }
999

1000
        // The query() function will set this flag to FALSE in the event that a query failed
1001
        if ($this->transStatus === false || $this->transFailure === true) {
68✔
1002
            try {
1003
                $this->transRollback();
23✔
1004
            } finally {
1005
                // If we are NOT running in strict mode, we will reset
1006
                // the _trans_status flag so that subsequent groups of
1007
                // transactions will be permitted.
1008
                if ($this->transStrict === false) {
23✔
1009
                    $this->transStatus = true;
23✔
1010
                }
1011
            }
1012

1013
            return false;
21✔
1014
        }
1015

1016
        return $this->transCommit();
50✔
1017
    }
1018

1019
    /**
1020
     * Lets you retrieve the transaction flag to determine if it has failed
1021
     */
1022
    public function transStatus(): bool
1023
    {
1024
        return $this->transStatus;
18✔
1025
    }
1026

1027
    /**
1028
     * Checks whether this connection is inside an active transaction.
1029
     */
1030
    public function inTransaction(): bool
1031
    {
1032
        return $this->transDepth > 0;
6✔
1033
    }
1034

1035
    /**
1036
     * Register a callback to run after the outermost transaction commits.
1037
     *
1038
     * If no transaction is active, the callback runs immediately.
1039
     *
1040
     * @param callable(): void $callback
1041
     *
1042
     * @return $this
1043
     */
1044
    public function afterCommit(callable $callback): static
1045
    {
1046
        if ($this->transDepth === 0) {
12✔
1047
            $callback();
2✔
1048

1049
            return $this;
2✔
1050
        }
1051

1052
        $this->transCommitCallbacks[] = $callback;
10✔
1053

1054
        return $this;
10✔
1055
    }
1056

1057
    /**
1058
     * Register a callback to run after the outermost transaction rolls back.
1059
     *
1060
     * If no transaction is active, the callback is not run.
1061
     *
1062
     * @param callable(): void $callback
1063
     *
1064
     * @return $this
1065
     */
1066
    public function afterRollback(callable $callback): static
1067
    {
1068
        if ($this->transDepth === 0) {
13✔
1069
            return $this;
1✔
1070
        }
1071

1072
        $this->transRollbackCallbacks[] = $callback;
12✔
1073

1074
        return $this;
12✔
1075
    }
1076

1077
    /**
1078
     * Run the callback inside a transaction.
1079
     *
1080
     * @template TReturn
1081
     *
1082
     * @param callable(self): TReturn $callback
1083
     *
1084
     * @return false|TReturn
1085
     */
1086
    public function transaction(callable $callback): mixed
1087
    {
1088
        if (! $this->transEnabled) {
15✔
1089
            return $callback($this);
1✔
1090
        }
1091

1092
        if (! $this->transBegin()) {
14✔
1093
            return false;
1✔
1094
        }
1095

1096
        try {
1097
            $result = $callback($this);
13✔
1098
        } catch (Throwable $e) {
5✔
1099
            try {
1100
                $this->transRollback();
5✔
1101
            } catch (Throwable $rollbackException) {
1✔
1102
                log_message('error', 'Database: Transaction callback threw an exception before rollback failed: ' . $e);
1✔
1103

1104
                throw $rollbackException;
1✔
1105
            } finally {
1106
                if ($this->transDepth > 0) {
5✔
1107
                    $this->transStatus = false;
1✔
1108
                } elseif ($this->transStrict === false) {
4✔
1109
                    $this->transStatus = true;
5✔
1110
                }
1111
            }
1112

1113
            throw $e;
4✔
1114
        }
1115

1116
        if (! $this->transComplete()) {
8✔
1117
            return false;
1✔
1118
        }
1119

1120
        return $result;
6✔
1121
    }
1122

1123
    /**
1124
     * Begin Transaction
1125
     */
1126
    public function transBegin(bool $testMode = false): bool
1127
    {
1128
        if (! $this->transEnabled) {
84✔
1129
            return false;
1✔
1130
        }
1131

1132
        // When transactions are nested we only begin/commit/rollback the outermost ones
1133
        if ($this->transDepth > 0) {
83✔
1134
            $this->transDepth++;
6✔
1135

1136
            return true;
6✔
1137
        }
1138

1139
        if (empty($this->connID)) {
83✔
1140
            $this->initialize();
6✔
1141
        }
1142

1143
        // Reset the transaction failure flag.
1144
        // If the $testMode flag is set to TRUE transactions will be rolled back
1145
        // even if the queries produce a successful result.
1146
        $this->transFailure = $testMode;
83✔
1147

1148
        if ($this->_transBegin()) {
83✔
1149
            $this->transDepth++;
82✔
1150

1151
            return true;
82✔
1152
        }
1153

1154
        return false;
1✔
1155
    }
1156

1157
    /**
1158
     * Commit Transaction
1159
     */
1160
    public function transCommit(): bool
1161
    {
1162
        if (! $this->transEnabled || $this->transDepth === 0) {
54✔
UNCOV
1163
            return false;
×
1164
        }
1165

1166
        // When transactions are nested we only begin/commit/rollback the outermost ones
1167
        if ($this->transDepth > 1 || $this->_transCommit()) {
54✔
1168
            $this->transDepth--;
54✔
1169

1170
            if ($this->transDepth === 0) {
54✔
1171
                $this->transRollbackCallbacks = [];
53✔
1172
                $this->runTransCommitCallbacks();
53✔
1173
            }
1174

1175
            return true;
52✔
1176
        }
1177

1178
        return false;
1✔
1179
    }
1180

1181
    /**
1182
     * Rollback Transaction
1183
     */
1184
    public function transRollback(): bool
1185
    {
1186
        if (! $this->transEnabled || $this->transDepth === 0) {
35✔
UNCOV
1187
            return false;
×
1188
        }
1189

1190
        // When transactions are nested we only begin/commit/rollback the outermost ones
1191
        if ($this->transDepth > 1 || $this->_transRollback()) {
35✔
1192
            $this->transDepth--;
35✔
1193

1194
            if ($this->transDepth === 0) {
35✔
1195
                $this->transCommitCallbacks = [];
35✔
1196
                $this->runTransRollbackCallbacks();
35✔
1197
            }
1198

1199
            return true;
31✔
1200
        }
1201

1202
        return false;
1✔
1203
    }
1204

1205
    /**
1206
     * Reset transaction status - to restart transactions after strict mode failure
1207
     */
1208
    public function resetTransStatus(): static
1209
    {
1210
        $this->transStatus = true;
5✔
1211

1212
        return $this;
5✔
1213
    }
1214

1215
    /**
1216
     * Handle transaction status when a query fails
1217
     *
1218
     * @internal This method is for internal database component use only
1219
     */
1220
    public function handleTransStatus(): void
1221
    {
1222
        if ($this->transDepth !== 0) {
45✔
1223
            $this->transStatus = false;
21✔
1224
        }
1225
    }
1226

1227
    /**
1228
     * Run and clear callbacks registered for a successful transaction commit.
1229
     */
1230
    protected function runTransCommitCallbacks(): void
1231
    {
1232
        $callbacks                  = $this->transCommitCallbacks;
53✔
1233
        $this->transCommitCallbacks = [];
53✔
1234

1235
        foreach ($callbacks as $callback) {
53✔
1236
            $callback();
9✔
1237
        }
1238
    }
1239

1240
    /**
1241
     * Run and clear callbacks registered for a transaction rollback.
1242
     */
1243
    protected function runTransRollbackCallbacks(): void
1244
    {
1245
        $callbacks                    = $this->transRollbackCallbacks;
35✔
1246
        $this->transRollbackCallbacks = [];
35✔
1247

1248
        foreach ($callbacks as $callback) {
35✔
1249
            $callback();
11✔
1250
        }
1251
    }
1252

1253
    /**
1254
     * Begin Transaction
1255
     */
1256
    abstract protected function _transBegin(): bool;
1257

1258
    /**
1259
     * Commit Transaction
1260
     */
1261
    abstract protected function _transCommit(): bool;
1262

1263
    /**
1264
     * Rollback Transaction
1265
     */
1266
    abstract protected function _transRollback(): bool;
1267

1268
    /**
1269
     * Returns a non-shared new instance of the query builder for this connection.
1270
     *
1271
     * @param array|string|TableName $tableName
1272
     *
1273
     * @return BaseBuilder
1274
     *
1275
     * @throws DatabaseException
1276
     */
1277
    public function table($tableName)
1278
    {
1279
        if (empty($tableName)) {
986✔
UNCOV
1280
            throw new DatabaseException('You must set the database table to be used with your query.');
×
1281
        }
1282

1283
        $className = str_replace('Connection', 'Builder', static::class);
986✔
1284

1285
        return new $className($tableName, $this);
986✔
1286
    }
1287

1288
    /**
1289
     * Returns a new instance of the BaseBuilder class with a cleared FROM clause.
1290
     */
1291
    public function newQuery(): BaseBuilder
1292
    {
1293
        // save table aliases
1294
        $tempAliases         = $this->aliasedTables;
14✔
1295
        $builder             = $this->table(',')->from([], true);
14✔
1296
        $this->aliasedTables = $tempAliases;
14✔
1297

1298
        return $builder;
14✔
1299
    }
1300

1301
    /**
1302
     * Creates a prepared statement with the database that can then
1303
     * be used to execute multiple statements against. Within the
1304
     * closure, you would build the query in any normal way, though
1305
     * the Query Builder is the expected manner.
1306
     *
1307
     * Example:
1308
     *    $stmt = $db->prepare(function($db)
1309
     *           {
1310
     *             return $db->table('users')
1311
     *                   ->where('id', 1)
1312
     *                     ->get();
1313
     *           })
1314
     *
1315
     * @param Closure(BaseConnection): mixed $func
1316
     *
1317
     * @return BasePreparedQuery|null
1318
     */
1319
    public function prepare(Closure $func, array $options = [])
1320
    {
1321
        if (empty($this->connID)) {
16✔
UNCOV
1322
            $this->initialize();
×
1323
        }
1324

1325
        $this->pretend();
16✔
1326

1327
        $sql = $func($this);
16✔
1328

1329
        $this->pretend(false);
16✔
1330

1331
        if ($sql instanceof QueryInterface) {
16✔
1332
            $sql = $sql->getOriginalQuery();
16✔
1333
        }
1334

1335
        $class = str_ireplace('Connection', 'PreparedQuery', static::class);
16✔
1336
        /** @var BasePreparedQuery $class */
1337
        $class = new $class($this);
16✔
1338

1339
        return $class->prepare($sql, $options);
16✔
1340
    }
1341

1342
    /**
1343
     * Returns the last query's statement object.
1344
     *
1345
     * @return Query
1346
     */
1347
    public function getLastQuery()
1348
    {
1349
        return $this->lastQuery;
11✔
1350
    }
1351

1352
    /**
1353
     * Returns a string representation of the last query's statement object.
1354
     */
1355
    public function showLastQuery(): string
1356
    {
UNCOV
1357
        return (string) $this->lastQuery;
×
1358
    }
1359

1360
    /**
1361
     * Returns the time we started to connect to this database in
1362
     * seconds with microseconds.
1363
     *
1364
     * Used by the Debug Toolbar's timeline.
1365
     */
1366
    public function getConnectStart(): ?float
1367
    {
1368
        return $this->connectTime;
1✔
1369
    }
1370

1371
    /**
1372
     * Returns the number of seconds with microseconds that it took
1373
     * to connect to the database.
1374
     *
1375
     * Used by the Debug Toolbar's timeline.
1376
     */
1377
    public function getConnectDuration(int $decimals = 6): string
1378
    {
1379
        return number_format($this->connectDuration, $decimals);
2✔
1380
    }
1381

1382
    /**
1383
     * Protect Identifiers
1384
     *
1385
     * This function is used extensively by the Query Builder class, and by
1386
     * a couple functions in this class.
1387
     * It takes a column or table name (optionally with an alias) and inserts
1388
     * the table prefix onto it. Some logic is necessary in order to deal with
1389
     * column names that include the path. Consider a query like this:
1390
     *
1391
     * SELECT hostname.database.table.column AS c FROM hostname.database.table
1392
     *
1393
     * Or a query with aliasing:
1394
     *
1395
     * SELECT m.member_id, m.member_name FROM members AS m
1396
     *
1397
     * Since the column name can include up to four segments (host, DB, table, column)
1398
     * or also have an alias prefix, we need to do a bit of work to figure this out and
1399
     * insert the table prefix (if it exists) in the proper position, and escape only
1400
     * the correct identifiers.
1401
     *
1402
     * @param array|int|string|TableName $item
1403
     * @param bool                       $prefixSingle       Prefix a table name with no segments?
1404
     * @param bool                       $protectIdentifiers Protect table or column names?
1405
     * @param bool                       $fieldExists        Supplied $item contains a column name?
1406
     *
1407
     * @return ($item is array ? array : string)
1408
     */
1409
    public function protectIdentifiers($item, bool $prefixSingle = false, ?bool $protectIdentifiers = null, bool $fieldExists = true)
1410
    {
1411
        if (! is_bool($protectIdentifiers)) {
1,149✔
1412
            $protectIdentifiers = $this->protectIdentifiers;
1,116✔
1413
        }
1414

1415
        if (is_array($item)) {
1,149✔
1416
            $escapedArray = [];
1✔
1417

1418
            foreach ($item as $k => $v) {
1✔
1419
                $escapedArray[$this->protectIdentifiers($k)] = $this->protectIdentifiers($v, $prefixSingle, $protectIdentifiers, $fieldExists);
1✔
1420
            }
1421

1422
            return $escapedArray;
1✔
1423
        }
1424

1425
        if ($item instanceof TableName) {
1,149✔
1426
            /** @psalm-suppress NoValue I don't know why ERROR. */
1427
            return $this->escapeTableName($item);
2✔
1428
        }
1429

1430
        // If you pass `['column1', 'column2']`, `$item` will be int because the array keys are int.
1431
        $item = (string) $item;
1,149✔
1432

1433
        // This is basically a bug fix for queries that use MAX, MIN, etc.
1434
        // If a parenthesis is found we know that we do not need to
1435
        // escape the data or add a prefix. There's probably a more graceful
1436
        // way to deal with this, but I'm not thinking of it
1437
        //
1438
        // Added exception for single quotes as well, we don't want to alter
1439
        // literal strings.
1440
        if (strcspn($item, "()'") !== strlen($item)) {
1,149✔
1441
            /** @psalm-suppress NoValue I don't know why ERROR. */
1442
            return $item;
837✔
1443
        }
1444

1445
        // Do not protect identifiers and do not prefix, no swap prefix, there is nothing to do
1446
        if ($protectIdentifiers === false && $prefixSingle === false && $this->swapPre === '') {
1,138✔
1447
            /** @psalm-suppress NoValue I don't know why ERROR. */
1448
            return $item;
105✔
1449
        }
1450

1451
        // Convert tabs or multiple spaces into single spaces
1452
        /** @psalm-suppress NoValue I don't know why ERROR. */
1453
        $item = preg_replace('/\s+/', ' ', trim($item));
1,137✔
1454

1455
        // If the item has an alias declaration we remove it and set it aside.
1456
        // Note: strripos() is used in order to support spaces in table names
1457
        if ($offset = strripos($item, ' AS ')) {
1,137✔
1458
            $alias = ($protectIdentifiers) ? substr($item, $offset, 4) . $this->escapeIdentifiers(substr($item, $offset + 4)) : substr($item, $offset);
11✔
1459
            $item  = substr($item, 0, $offset);
11✔
1460
        } elseif ($offset = strrpos($item, ' ')) {
1,132✔
1461
            $alias = ($protectIdentifiers) ? ' ' . $this->escapeIdentifiers(substr($item, $offset + 1)) : substr($item, $offset);
12✔
1462
            $item  = substr($item, 0, $offset);
12✔
1463
        } else {
1464
            $alias = '';
1,126✔
1465
        }
1466

1467
        // Break the string apart if it contains periods, then insert the table prefix
1468
        // in the correct location, assuming the period doesn't indicate that we're dealing
1469
        // with an alias. While we're at it, we will escape the components
1470
        if (str_contains($item, '.')) {
1,137✔
1471
            return $this->protectDotItem($item, $alias, $protectIdentifiers, $fieldExists);
147✔
1472
        }
1473

1474
        // In some cases, especially 'from', we end up running through
1475
        // protect_identifiers twice. This algorithm won't work when
1476
        // it contains the escapeChar so strip it out.
1477
        $item = trim($item, $this->escapeChar);
1,129✔
1478

1479
        // Is there a table prefix? If not, no need to insert it
1480
        if ($this->DBPrefix !== '') {
1,129✔
1481
            // Verify table prefix and replace if necessary
1482
            if ($this->swapPre !== '' && str_starts_with($item, $this->swapPre)) {
877✔
UNCOV
1483
                $item = preg_replace('/^' . $this->swapPre . '(\S+?)/', $this->DBPrefix . '\\1', $item);
×
1484
            }
1485
            // Do we prefix an item with no segments?
1486
            elseif ($prefixSingle && ! str_starts_with($item, $this->DBPrefix)) {
877✔
1487
                $item = $this->DBPrefix . $item;
870✔
1488
            }
1489
        }
1490

1491
        if ($protectIdentifiers === true && ! in_array($item, $this->reservedIdentifiers, true)) {
1,129✔
1492
            $item = $this->escapeIdentifiers($item);
1,127✔
1493
        }
1494

1495
        return $item . $alias;
1,129✔
1496
    }
1497

1498
    private function protectDotItem(string $item, string $alias, bool $protectIdentifiers, bool $fieldExists): string
1499
    {
1500
        $parts = explode('.', $item);
147✔
1501

1502
        // Does the first segment of the exploded item match
1503
        // one of the aliases previously identified? If so,
1504
        // we have nothing more to do other than escape the item
1505
        //
1506
        // NOTE: The ! empty() condition prevents this method
1507
        // from breaking when QB isn't enabled.
1508
        if (! empty($this->aliasedTables) && in_array($parts[0], $this->aliasedTables, true)) {
147✔
1509
            if ($protectIdentifiers) {
10✔
1510
                foreach ($parts as $key => $val) {
10✔
1511
                    if (! in_array($val, $this->reservedIdentifiers, true)) {
10✔
1512
                        $parts[$key] = $this->escapeIdentifiers($val);
10✔
1513
                    }
1514
                }
1515

1516
                $item = implode('.', $parts);
10✔
1517
            }
1518

1519
            return $item . $alias;
10✔
1520
        }
1521

1522
        // Is there a table prefix defined in the config file? If not, no need to do anything
1523
        if ($this->DBPrefix !== '') {
141✔
1524
            // We now add the table prefix based on some logic.
1525
            // Do we have 4 segments (hostname.database.table.column)?
1526
            // If so, we add the table prefix to the column name in the 3rd segment.
1527
            if (isset($parts[3])) {
133✔
UNCOV
1528
                $i = 2;
×
1529
            }
1530
            // Do we have 3 segments (database.table.column)?
1531
            // If so, we add the table prefix to the column name in 2nd position
1532
            elseif (isset($parts[2])) {
133✔
UNCOV
1533
                $i = 1;
×
1534
            }
1535
            // Do we have 2 segments (table.column)?
1536
            // If so, we add the table prefix to the column name in 1st segment
1537
            else {
1538
                $i = 0;
133✔
1539
            }
1540

1541
            // This flag is set when the supplied $item does not contain a field name.
1542
            // This can happen when this function is being called from a JOIN.
1543
            if ($fieldExists === false) {
133✔
UNCOV
1544
                $i++;
×
1545
            }
1546

1547
            // Verify table prefix and replace if necessary
1548
            if ($this->swapPre !== '' && str_starts_with($parts[$i], $this->swapPre)) {
133✔
UNCOV
1549
                $parts[$i] = preg_replace('/^' . $this->swapPre . '(\S+?)/', $this->DBPrefix . '\\1', $parts[$i]);
×
1550
            }
1551
            // We only add the table prefix if it does not already exist
1552
            elseif (! str_starts_with($parts[$i], $this->DBPrefix)) {
133✔
1553
                $parts[$i] = $this->DBPrefix . $parts[$i];
133✔
1554
            }
1555

1556
            // Put the parts back together
1557
            $item = implode('.', $parts);
133✔
1558
        }
1559

1560
        if ($protectIdentifiers) {
141✔
1561
            $item = $this->escapeIdentifiers($item);
141✔
1562
        }
1563

1564
        return $item . $alias;
141✔
1565
    }
1566

1567
    /**
1568
     * Escape the SQL Identifier
1569
     *
1570
     * This function escapes single identifier.
1571
     *
1572
     * @param non-empty-string|TableName $item
1573
     */
1574
    public function escapeIdentifier($item): string
1575
    {
1576
        if ($item === '') {
769✔
UNCOV
1577
            return '';
×
1578
        }
1579

1580
        if ($item instanceof TableName) {
769✔
1581
            return $this->escapeTableName($item);
7✔
1582
        }
1583

1584
        return $this->escapeChar
769✔
1585
            . str_replace(
769✔
1586
                $this->escapeChar,
769✔
1587
                $this->escapeChar . $this->escapeChar,
769✔
1588
                $item,
769✔
1589
            )
769✔
1590
            . $this->escapeChar;
769✔
1591
    }
1592

1593
    /**
1594
     * Returns escaped table name with alias.
1595
     */
1596
    private function escapeTableName(TableName $tableName): string
1597
    {
1598
        $alias = $tableName->getAlias();
7✔
1599

1600
        return $this->escapeIdentifier($tableName->getActualTableName())
7✔
1601
            . (($alias !== '') ? ' ' . $this->escapeIdentifier($alias) : '');
7✔
1602
    }
1603

1604
    /**
1605
     * Escape the SQL Identifiers
1606
     *
1607
     * This function escapes column and table names
1608
     *
1609
     * @param array|string $item
1610
     *
1611
     * @return ($item is array ? array : string)
1612
     */
1613
    public function escapeIdentifiers($item)
1614
    {
1615
        if ($this->escapeChar === '' || empty($item) || in_array($item, $this->reservedIdentifiers, true)) {
1,155✔
1616
            return $item;
5✔
1617
        }
1618

1619
        if (is_array($item)) {
1,154✔
1620
            foreach ($item as $key => $value) {
782✔
1621
                $item[$key] = $this->escapeIdentifiers($value);
782✔
1622
            }
1623

1624
            return $item;
782✔
1625
        }
1626

1627
        // Avoid breaking functions and literal values inside queries
1628
        if (ctype_digit($item)
1,154✔
1629
            || $item[0] === "'"
1,153✔
1630
            || ($this->escapeChar !== '"' && $item[0] === '"')
1,153✔
1631
            || str_contains($item, '(')) {
1,154✔
1632
            return $item;
47✔
1633
        }
1634

1635
        if ($this->pregEscapeChar === []) {
1,153✔
1636
            if (is_array($this->escapeChar)) {
328✔
UNCOV
1637
                $this->pregEscapeChar = [
×
UNCOV
1638
                    preg_quote($this->escapeChar[0], '/'),
×
UNCOV
1639
                    preg_quote($this->escapeChar[1], '/'),
×
UNCOV
1640
                    $this->escapeChar[0],
×
UNCOV
1641
                    $this->escapeChar[1],
×
UNCOV
1642
                ];
×
1643
            } else {
1644
                $this->pregEscapeChar[0] = $this->pregEscapeChar[1] = preg_quote($this->escapeChar, '/');
328✔
1645
                $this->pregEscapeChar[2] = $this->pregEscapeChar[3] = $this->escapeChar;
328✔
1646
            }
1647
        }
1648

1649
        foreach ($this->reservedIdentifiers as $id) {
1,153✔
1650
            /** @psalm-suppress NoValue I don't know why ERROR. */
1651
            if (str_contains($item, '.' . $id)) {
1,153✔
1652
                return preg_replace(
3✔
1653
                    '/' . $this->pregEscapeChar[0] . '?([^' . $this->pregEscapeChar[1] . '\.]+)' . $this->pregEscapeChar[1] . '?\./i',
3✔
1654
                    $this->pregEscapeChar[2] . '$1' . $this->pregEscapeChar[3] . '.',
3✔
1655
                    $item,
3✔
1656
                );
3✔
1657
            }
1658
        }
1659

1660
        /** @psalm-suppress NoValue I don't know why ERROR. */
1661
        return preg_replace(
1,151✔
1662
            '/' . $this->pregEscapeChar[0] . '?([^' . $this->pregEscapeChar[1] . '\.]+)' . $this->pregEscapeChar[1] . '?(\.)?/i',
1,151✔
1663
            $this->pregEscapeChar[2] . '$1' . $this->pregEscapeChar[3] . '$2',
1,151✔
1664
            $item,
1,151✔
1665
        );
1,151✔
1666
    }
1667

1668
    /**
1669
     * Prepends a database prefix if one exists in configuration
1670
     *
1671
     * @throws DatabaseException
1672
     */
1673
    public function prefixTable(string $table = ''): string
1674
    {
1675
        if ($table === '') {
3✔
UNCOV
1676
            throw new DatabaseException('A table name is required for that operation.');
×
1677
        }
1678

1679
        return $this->DBPrefix . $table;
3✔
1680
    }
1681

1682
    /**
1683
     * Returns the total number of rows affected by this query.
1684
     */
1685
    abstract public function affectedRows(): int;
1686

1687
    /**
1688
     * "Smart" Escape String
1689
     *
1690
     * Escapes data based on type.
1691
     * Sets boolean and null types
1692
     *
1693
     * @param array|bool|float|int|object|string|null $str
1694
     *
1695
     * @return ($str is array ? array : float|int|string)
1696
     */
1697
    public function escape($str)
1698
    {
1699
        if (is_array($str)) {
971✔
1700
            return array_map($this->escape(...), $str);
798✔
1701
        }
1702

1703
        if ($str instanceof Stringable) {
971✔
1704
            if ($str instanceof RawSql) {
13✔
1705
                return $str->__toString();
12✔
1706
            }
1707

1708
            $str = (string) $str;
1✔
1709
        }
1710

1711
        if (is_string($str)) {
968✔
1712
            return "'" . $this->escapeString($str) . "'";
917✔
1713
        }
1714

1715
        if (is_bool($str)) {
889✔
1716
            return ($str === false) ? 0 : 1;
8✔
1717
        }
1718

1719
        return $str ?? 'NULL';
887✔
1720
    }
1721

1722
    /**
1723
     * Escape String
1724
     *
1725
     * @param list<string|Stringable>|string|Stringable $str  Input string
1726
     * @param bool                                      $like Whether the string will be used in a LIKE condition
1727
     *
1728
     * @return list<string>|string
1729
     */
1730
    public function escapeString($str, bool $like = false)
1731
    {
1732
        if (is_array($str)) {
917✔
UNCOV
1733
            foreach ($str as $key => $val) {
×
1734
                $str[$key] = $this->escapeString($val, $like);
×
1735
            }
1736

UNCOV
1737
            return $str;
×
1738
        }
1739

1740
        if ($str instanceof Stringable) {
917✔
1741
            if ($str instanceof RawSql) {
2✔
UNCOV
1742
                return $str->__toString();
×
1743
            }
1744

1745
            $str = (string) $str;
2✔
1746
        }
1747

1748
        $str = $this->_escapeString($str);
917✔
1749

1750
        // escape LIKE condition wildcards
1751
        if ($like) {
917✔
1752
            return str_replace(
2✔
1753
                [
2✔
1754
                    $this->likeEscapeChar,
2✔
1755
                    '%',
2✔
1756
                    '_',
2✔
1757
                ],
2✔
1758
                [
2✔
1759
                    $this->likeEscapeChar . $this->likeEscapeChar,
2✔
1760
                    $this->likeEscapeChar . '%',
2✔
1761
                    $this->likeEscapeChar . '_',
2✔
1762
                ],
2✔
1763
                $str,
2✔
1764
            );
2✔
1765
        }
1766

1767
        return $str;
917✔
1768
    }
1769

1770
    /**
1771
     * Escape LIKE String
1772
     *
1773
     * Calls the individual driver for platform
1774
     * specific escaping for LIKE conditions
1775
     *
1776
     * @param list<string|Stringable>|string|Stringable $str
1777
     *
1778
     * @return list<string>|string
1779
     */
1780
    public function escapeLikeString($str)
1781
    {
1782
        return $this->escapeString($str, true);
2✔
1783
    }
1784

1785
    /**
1786
     * Platform independent string escape.
1787
     *
1788
     * Will likely be overridden in child classes.
1789
     */
1790
    protected function _escapeString(string $str): string
1791
    {
1792
        return str_replace("'", "''", remove_invisible_characters($str, false));
873✔
1793
    }
1794

1795
    /**
1796
     * This function enables you to call PHP database functions that are not natively included
1797
     * in CodeIgniter, in a platform independent manner.
1798
     *
1799
     * @param array ...$params
1800
     *
1801
     * @throws DatabaseException
1802
     */
1803
    public function callFunction(string $functionName, ...$params): bool
1804
    {
1805
        $driver = $this->getDriverFunctionPrefix();
2✔
1806

1807
        if (! str_starts_with($functionName, $driver)) {
2✔
1808
            $functionName = $driver . $functionName;
1✔
1809
        }
1810

1811
        if (! function_exists($functionName)) {
2✔
UNCOV
1812
            if ($this->DBDebug) {
×
UNCOV
1813
                throw new DatabaseException('This feature is not available for the database you are using.');
×
1814
            }
1815

UNCOV
1816
            return false;
×
1817
        }
1818

1819
        return $functionName(...$params);
2✔
1820
    }
1821

1822
    /**
1823
     * Get the prefix of the function to access the DB.
1824
     */
1825
    protected function getDriverFunctionPrefix(): string
1826
    {
UNCOV
1827
        return strtolower($this->DBDriver) . '_';
×
1828
    }
1829

1830
    // --------------------------------------------------------------------
1831
    // META Methods
1832
    // --------------------------------------------------------------------
1833

1834
    /**
1835
     * Returns an array of table names
1836
     *
1837
     * @return false|list<string>
1838
     *
1839
     * @throws DatabaseException
1840
     */
1841
    public function listTables(bool $constrainByPrefix = false)
1842
    {
1843
        if (isset($this->dataCache['table_names']) && $this->dataCache['table_names']) {
830✔
1844
            return $constrainByPrefix
824✔
1845
                ? preg_grep("/^{$this->DBPrefix}/", $this->dataCache['table_names'])
2✔
1846
                : $this->dataCache['table_names'];
824✔
1847
        }
1848

1849
        $sql = $this->_listTables($constrainByPrefix);
84✔
1850

1851
        if ($sql === false) {
84✔
UNCOV
1852
            if ($this->DBDebug) {
×
UNCOV
1853
                throw new DatabaseException('This feature is not available for the database you are using.');
×
1854
            }
1855

UNCOV
1856
            return false;
×
1857
        }
1858

1859
        $this->dataCache['table_names'] = [];
84✔
1860

1861
        $query = $this->query($sql);
84✔
1862

1863
        foreach ($query->getResultArray() as $row) {
84✔
1864
            /** @var string $table */
1865
            $table = $row['table_name'] ?? $row['TABLE_NAME'] ?? $row[array_key_first($row)];
81✔
1866

1867
            $this->dataCache['table_names'][] = $table;
81✔
1868
        }
1869

1870
        return $this->dataCache['table_names'];
84✔
1871
    }
1872

1873
    /**
1874
     * Determine if a particular table exists
1875
     *
1876
     * @param bool $cached Whether to use data cache
1877
     */
1878
    public function tableExists(string $tableName, bool $cached = true): bool
1879
    {
1880
        if ($cached) {
824✔
1881
            return in_array($this->protectIdentifiers($tableName, true, false, false), $this->listTables(), true);
823✔
1882
        }
1883

1884
        if (false === ($sql = $this->_listTables(false, $tableName))) {
777✔
UNCOV
1885
            if ($this->DBDebug) {
×
UNCOV
1886
                throw new DatabaseException('This feature is not available for the database you are using.');
×
1887
            }
1888

UNCOV
1889
            return false;
×
1890
        }
1891

1892
        $tableExists = $this->query($sql)->getResultArray() !== [];
777✔
1893

1894
        // if cache has been built already
1895
        if (! empty($this->dataCache['table_names'])) {
777✔
1896
            $key = array_search(
773✔
1897
                strtolower($tableName),
773✔
1898
                array_map(strtolower(...), $this->dataCache['table_names']),
773✔
1899
                true,
773✔
1900
            );
773✔
1901

1902
            // table doesn't exist but still in cache - lets reset cache, it can be rebuilt later
1903
            // OR if table does exist but is not found in cache
1904
            if (($key !== false && ! $tableExists) || ($key === false && $tableExists)) {
773✔
1905
                $this->resetDataCache();
1✔
1906
            }
1907
        }
1908

1909
        return $tableExists;
777✔
1910
    }
1911

1912
    /**
1913
     * Fetch Field Names
1914
     *
1915
     * @param string|TableName $tableName
1916
     *
1917
     * @return false|list<string>
1918
     *
1919
     * @throws DatabaseException
1920
     */
1921
    public function getFieldNames($tableName)
1922
    {
1923
        $table = ($tableName instanceof TableName) ? $tableName->getTableName() : $tableName;
12✔
1924

1925
        // Is there a cached result?
1926
        if (isset($this->dataCache['field_names'][$table])) {
12✔
1927
            return $this->dataCache['field_names'][$table];
7✔
1928
        }
1929

1930
        if (empty($this->connID)) {
8✔
1931
            $this->initialize();
×
1932
        }
1933

1934
        if (false === ($sql = $this->_listColumns($tableName))) {
8✔
UNCOV
1935
            if ($this->DBDebug) {
×
UNCOV
1936
                throw new DatabaseException('This feature is not available for the database you are using.');
×
1937
            }
1938

UNCOV
1939
            return false;
×
1940
        }
1941

1942
        $query = $this->query($sql);
8✔
1943

1944
        $this->dataCache['field_names'][$table] = [];
8✔
1945

1946
        foreach ($query->getResultArray() as $row) {
8✔
1947
            // Do we know from where to get the column's name?
1948
            if (! isset($key)) {
8✔
1949
                if (isset($row['column_name'])) {
8✔
1950
                    $key = 'column_name';
8✔
1951
                } elseif (isset($row['COLUMN_NAME'])) {
8✔
1952
                    $key = 'COLUMN_NAME';
8✔
1953
                } else {
1954
                    // We have no other choice but to just get the first element's key.
1955
                    $key = key($row);
8✔
1956
                }
1957
            }
1958

1959
            $this->dataCache['field_names'][$table][] = $row[$key];
8✔
1960
        }
1961

1962
        return $this->dataCache['field_names'][$table];
8✔
1963
    }
1964

1965
    /**
1966
     * Determine if a particular field exists
1967
     */
1968
    public function fieldExists(string $fieldName, string $tableName): bool
1969
    {
1970
        return in_array($fieldName, $this->getFieldNames($tableName), true);
8✔
1971
    }
1972

1973
    /**
1974
     * Returns an object with field data
1975
     *
1976
     * @return list<stdClass>
1977
     */
1978
    public function getFieldData(string $table)
1979
    {
1980
        return $this->_fieldData($this->protectIdentifiers($table, true, false, false));
150✔
1981
    }
1982

1983
    /**
1984
     * Returns an object with key data
1985
     *
1986
     * @return array<string, stdClass>
1987
     */
1988
    public function getIndexData(string $table)
1989
    {
1990
        return $this->_indexData($this->protectIdentifiers($table, true, false, false));
165✔
1991
    }
1992

1993
    /**
1994
     * Returns an object with foreign key data
1995
     *
1996
     * @return array<string, stdClass>
1997
     */
1998
    public function getForeignKeyData(string $table)
1999
    {
2000
        return $this->_foreignKeyData($this->protectIdentifiers($table, true, false, false));
37✔
2001
    }
2002

2003
    /**
2004
     * Converts array of arrays generated by _foreignKeyData() to array of objects
2005
     *
2006
     * @return array<string, stdClass>
2007
     *
2008
     * array[
2009
     *    {constraint_name} =>
2010
     *        stdClass[
2011
     *            'constraint_name'     => string,
2012
     *            'table_name'          => string,
2013
     *            'column_name'         => string[],
2014
     *            'foreign_table_name'  => string,
2015
     *            'foreign_column_name' => string[],
2016
     *            'on_delete'           => string,
2017
     *            'on_update'           => string,
2018
     *            'match'               => string
2019
     *        ]
2020
     * ]
2021
     */
2022
    protected function foreignKeyDataToObjects(array $data)
2023
    {
2024
        $retVal = [];
37✔
2025

2026
        foreach ($data as $row) {
37✔
2027
            $name = $row['constraint_name'];
12✔
2028

2029
            // for sqlite generate name
2030
            if ($name === null) {
12✔
2031
                $name = $row['table_name'] . '_' . implode('_', $row['column_name']) . '_foreign';
11✔
2032
            }
2033

2034
            $obj                      = new stdClass();
12✔
2035
            $obj->constraint_name     = $name;
12✔
2036
            $obj->table_name          = $row['table_name'];
12✔
2037
            $obj->column_name         = $row['column_name'];
12✔
2038
            $obj->foreign_table_name  = $row['foreign_table_name'];
12✔
2039
            $obj->foreign_column_name = $row['foreign_column_name'];
12✔
2040
            $obj->on_delete           = $row['on_delete'];
12✔
2041
            $obj->on_update           = $row['on_update'];
12✔
2042
            $obj->match               = $row['match'];
12✔
2043

2044
            $retVal[$name] = $obj;
12✔
2045
        }
2046

2047
        return $retVal;
37✔
2048
    }
2049

2050
    /**
2051
     * Disables foreign key checks temporarily.
2052
     *
2053
     * @return bool
2054
     */
2055
    public function disableForeignKeyChecks()
2056
    {
2057
        $sql = $this->_disableForeignKeyChecks();
791✔
2058

2059
        if ($sql === '') {
791✔
2060
            // The feature is not supported.
UNCOV
2061
            return false;
×
2062
        }
2063

2064
        return $this->query($sql);
791✔
2065
    }
2066

2067
    /**
2068
     * Enables foreign key checks temporarily.
2069
     *
2070
     * @return bool
2071
     */
2072
    public function enableForeignKeyChecks()
2073
    {
2074
        $sql = $this->_enableForeignKeyChecks();
874✔
2075

2076
        if ($sql === '') {
874✔
2077
            // The feature is not supported.
UNCOV
2078
            return false;
×
2079
        }
2080

2081
        return $this->query($sql);
874✔
2082
    }
2083

2084
    /**
2085
     * Allows the engine to be set into a mode where queries are not
2086
     * actually executed, but they are still generated, timed, etc.
2087
     *
2088
     * This is primarily used by the prepared query functionality.
2089
     *
2090
     * @return $this
2091
     */
2092
    public function pretend(bool $pretend = true)
2093
    {
2094
        $this->pretend = $pretend;
17✔
2095

2096
        return $this;
17✔
2097
    }
2098

2099
    /**
2100
     * Empties our data cache. Especially helpful during testing.
2101
     *
2102
     * @return $this
2103
     */
2104
    public function resetDataCache()
2105
    {
2106
        $this->dataCache = [];
36✔
2107

2108
        return $this;
36✔
2109
    }
2110

2111
    /**
2112
     * Determines if the statement is a write-type query or not.
2113
     *
2114
     * @param string $sql
2115
     */
2116
    public function isWriteType($sql): bool
2117
    {
2118
        return (bool) preg_match('/^\s*(WITH\s.+(\s|[)]))?"?(SET|INSERT|UPDATE|DELETE|REPLACE|CREATE|DROP|TRUNCATE|LOAD|COPY|ALTER|RENAME|GRANT|REVOKE|LOCK|UNLOCK|REINDEX|MERGE)\s(?!.*\sRETURNING\s)/is', $sql);
900✔
2119
    }
2120

2121
    /**
2122
     * Returns the last error code and message.
2123
     *
2124
     * Must return an array with keys 'code' and 'message':
2125
     *
2126
     * @return array{code: int|string|null, message: string|null}
2127
     */
2128
    abstract public function error(): array;
2129

2130
    /**
2131
     * Returns the exception that would have been thrown on the last failed
2132
     * query if DBDebug were enabled. Returns null if the last query succeeded
2133
     * or if DBDebug is true (in which case the exception is always thrown
2134
     * directly and this method will always return null).
2135
     */
2136
    public function getLastException(): ?DatabaseException
2137
    {
2138
        return $this->lastException;
4✔
2139
    }
2140

2141
    /**
2142
     * Insert ID
2143
     *
2144
     * @return int|string
2145
     */
2146
    abstract public function insertID();
2147

2148
    /**
2149
     * Generates the SQL for listing tables in a platform-dependent manner.
2150
     *
2151
     * @param string|null $tableName If $tableName is provided will return only this table if exists.
2152
     *
2153
     * @return false|string
2154
     */
2155
    abstract protected function _listTables(bool $constrainByPrefix = false, ?string $tableName = null);
2156

2157
    /**
2158
     * Generates a platform-specific query string so that the column names can be fetched.
2159
     *
2160
     * @param string|TableName $table
2161
     *
2162
     * @return false|string
2163
     */
2164
    abstract protected function _listColumns($table = '');
2165

2166
    /**
2167
     * Platform-specific field data information.
2168
     *
2169
     * @see getFieldData()
2170
     *
2171
     * @return list<stdClass>
2172
     */
2173
    abstract protected function _fieldData(string $table): array;
2174

2175
    /**
2176
     * Platform-specific index data.
2177
     *
2178
     * @see    getIndexData()
2179
     *
2180
     * @return array<string, stdClass>
2181
     */
2182
    abstract protected function _indexData(string $table): array;
2183

2184
    /**
2185
     * Platform-specific foreign keys data.
2186
     *
2187
     * @see    getForeignKeyData()
2188
     *
2189
     * @return array<string, stdClass>
2190
     */
2191
    abstract protected function _foreignKeyData(string $table): array;
2192

2193
    /**
2194
     * Platform-specific SQL statement to disable foreign key checks.
2195
     *
2196
     * If this feature is not supported, return empty string.
2197
     *
2198
     * @TODO This method should be moved to an interface that represents foreign key support.
2199
     *
2200
     * @return string
2201
     *
2202
     * @see disableForeignKeyChecks()
2203
     */
2204
    protected function _disableForeignKeyChecks()
2205
    {
UNCOV
2206
        return '';
×
2207
    }
2208

2209
    /**
2210
     * Platform-specific SQL statement to enable foreign key checks.
2211
     *
2212
     * If this feature is not supported, return empty string.
2213
     *
2214
     * @TODO This method should be moved to an interface that represents foreign key support.
2215
     *
2216
     * @return string
2217
     *
2218
     * @see enableForeignKeyChecks()
2219
     */
2220
    protected function _enableForeignKeyChecks()
2221
    {
UNCOV
2222
        return '';
×
2223
    }
2224

2225
    /**
2226
     * Converts a named timezone to an offset string.
2227
     *
2228
     * Converts timezone identifiers (e.g., 'America/New_York') to offset strings
2229
     * (e.g., '-05:00' or '-04:00' depending on DST). This is useful because not all
2230
     * databases have timezone tables loaded, but all support offset notation.
2231
     *
2232
     * @param string $timezone Named timezone (e.g., 'America/New_York', 'UTC', 'Europe/Paris')
2233
     *
2234
     * @return string Offset string (e.g., '+00:00', '-05:00', '+01:00')
2235
     */
2236
    protected function convertTimezoneToOffset(string $timezone): string
2237
    {
2238
        // If it's already an offset, return as-is
2239
        if (preg_match('/^[+-]\d{2}:\d{2}$/', $timezone)) {
9✔
2240
            return $timezone;
3✔
2241
        }
2242

2243
        try {
2244
            $offset = Time::now($timezone)->getOffset();
6✔
2245

2246
            // Convert offset seconds to +-HH:MM format
2247
            $hours   = (int) ($offset / 3600);
5✔
2248
            $minutes = abs((int) (($offset % 3600) / 60));
5✔
2249

2250
            return sprintf('%+03d:%02d', $hours, $minutes);
5✔
2251
        } catch (Exception $e) {
1✔
2252
            // If timezone conversion fails, log and return UTC
2253
            log_message('error', "Invalid timezone '{$timezone}'. Falling back to UTC. {$e->getMessage()}.");
1✔
2254

2255
            return '+00:00';
1✔
2256
        }
2257
    }
2258

2259
    /**
2260
     * Gets the timezone string to use for database session.
2261
     *
2262
     * Handles the timezone configuration logic:
2263
     * - false: Don't set timezone (returns null)
2264
     * - true: Auto-sync with app timezone from config
2265
     * - string: Use specific timezone (converts named timezones to offsets)
2266
     *
2267
     * @return string|null The timezone offset string, or null if timezone should not be set
2268
     */
2269
    protected function getSessionTimezone(): ?string
2270
    {
2271
        if ($this->timezone === false) {
68✔
2272
            return null;
62✔
2273
        }
2274

2275
        // Auto-sync with app timezone
2276
        if ($this->timezone === true) {
6✔
2277
            $appConfig = config('App');
2✔
2278
            $timezone  = $appConfig->appTimezone;
2✔
2279
        } else {
2280
            // Use specific timezone from config
2281
            $timezone = $this->timezone;
4✔
2282
        }
2283

2284
        return $this->convertTimezoneToOffset($timezone);
6✔
2285
    }
2286

2287
    /**
2288
     * Accessor for properties if they exist.
2289
     *
2290
     * @return array|bool|float|int|object|resource|string|null
2291
     */
2292
    public function __get(string $key)
2293
    {
2294
        if (property_exists($this, $key)) {
1,128✔
2295
            return $this->{$key};
1,127✔
2296
        }
2297

2298
        return null;
1✔
2299
    }
2300

2301
    /**
2302
     * Checker for properties existence.
2303
     */
2304
    public function __isset(string $key): bool
2305
    {
2306
        return property_exists($this, $key);
250✔
2307
    }
2308
}
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