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

codeigniter4 / CodeIgniter4 / 25171993582

30 Apr 2026 02:46PM UTC coverage: 88.358% (+0.02%) from 88.343%
25171993582

Pull #10148

github

web-flow
Merge ad86f80a4 into 466d0f7e4
Pull Request #10148: feat: add closure-based database transaction helper

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

33 existing lines in 1 file now uncovered.

23322 of 26395 relevant lines covered (88.36%)

218.96 hits per line

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

90.61
/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'])) {
477✔
408
            $this->dateFormat = array_merge($this->dateFormat, $params['dateFormat']);
151✔
409
            unset($params['dateFormat']);
151✔
410
        }
411

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

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

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

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

429
        if ($this->failover !== []) {
476✔
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)) {
183✔
447
            return $value;
163✔
448
        }
449

450
        if ($types === [] || in_array('string', $types, true) || in_array('mixed', $types, true)) {
183✔
451
            return $value;
183✔
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;
477✔
497
        $requested = array_fill_keys($properties, true);
477✔
498

499
        if (! isset(self::$propertyBuiltinTypesCache[$className])) {
477✔
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]);
477✔
505

506
        if ($missing !== []) {
477✔
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 = [];
477✔
549

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

554
        return $typedProperties;
477✔
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) {
880✔
573
            return;
814✔
574
        }
575

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

579
        try {
580
            // Connect to the database and set the connection ID
581
            $this->connID = $this->connect($this->pConnect);
82✔
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) {
82✔
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;
80✔
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;
1✔
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) {
67✔
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) {
67✔
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();
49✔
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
     * Register a callback to run after the outermost transaction commits.
1029
     *
1030
     * If no transaction is active, the callback runs immediately.
1031
     *
1032
     * @param callable(): void $callback
1033
     *
1034
     * @return $this
1035
     */
1036
    public function afterCommit(callable $callback): static
1037
    {
1038
        if ($this->transDepth === 0) {
11✔
1039
            $callback();
2✔
1040

1041
            return $this;
2✔
1042
        }
1043

1044
        $this->transCommitCallbacks[] = $callback;
9✔
1045

1046
        return $this;
9✔
1047
    }
1048

1049
    /**
1050
     * Register a callback to run after the outermost transaction rolls back.
1051
     *
1052
     * If no transaction is active, the callback is not run.
1053
     *
1054
     * @param callable(): void $callback
1055
     *
1056
     * @return $this
1057
     */
1058
    public function afterRollback(callable $callback): static
1059
    {
1060
        if ($this->transDepth === 0) {
12✔
1061
            return $this;
1✔
1062
        }
1063

1064
        $this->transRollbackCallbacks[] = $callback;
11✔
1065

1066
        return $this;
11✔
1067
    }
1068

1069
    /**
1070
     * Run the callback inside a transaction.
1071
     *
1072
     * @template TReturn
1073
     *
1074
     * @param callable(self): TReturn $callback
1075
     *
1076
     * @return false|TReturn
1077
     */
1078
    public function transaction(callable $callback): mixed
1079
    {
1080
        if (! $this->transEnabled) {
14✔
1081
            return $callback($this);
1✔
1082
        }
1083

1084
        if (! $this->transBegin()) {
13✔
1085
            return false;
1✔
1086
        }
1087

1088
        try {
1089
            $result = $callback($this);
12✔
1090
        } catch (Throwable $e) {
5✔
1091
            try {
1092
                $this->transRollback();
5✔
1093
            } finally {
1094
                if ($this->transDepth > 0) {
5✔
1095
                    $this->transStatus = false;
1✔
1096
                } elseif ($this->transStrict === false) {
4✔
1097
                    $this->transStatus = true;
5✔
1098
                }
1099
            }
1100

1101
            throw $e;
4✔
1102
        }
1103

1104
        if (! $this->transComplete()) {
7✔
1105
            return false;
1✔
1106
        }
1107

1108
        return $result;
5✔
1109
    }
1110

1111
    /**
1112
     * Begin Transaction
1113
     */
1114
    public function transBegin(bool $testMode = false): bool
1115
    {
1116
        if (! $this->transEnabled) {
80✔
UNCOV
1117
            return false;
×
1118
        }
1119

1120
        // When transactions are nested we only begin/commit/rollback the outermost ones
1121
        if ($this->transDepth > 0) {
80✔
1122
            $this->transDepth++;
5✔
1123

1124
            return true;
5✔
1125
        }
1126

1127
        if (empty($this->connID)) {
80✔
1128
            $this->initialize();
3✔
1129
        }
1130

1131
        // Reset the transaction failure flag.
1132
        // If the $testMode flag is set to TRUE transactions will be rolled back
1133
        // even if the queries produce a successful result.
1134
        $this->transFailure = $testMode;
80✔
1135

1136
        if ($this->_transBegin()) {
80✔
1137
            $this->transDepth++;
79✔
1138

1139
            return true;
79✔
1140
        }
1141

1142
        return false;
1✔
1143
    }
1144

1145
    /**
1146
     * Commit Transaction
1147
     */
1148
    public function transCommit(): bool
1149
    {
1150
        if (! $this->transEnabled || $this->transDepth === 0) {
51✔
UNCOV
1151
            return false;
×
1152
        }
1153

1154
        // When transactions are nested we only begin/commit/rollback the outermost ones
1155
        if ($this->transDepth > 1 || $this->_transCommit()) {
51✔
1156
            $this->transDepth--;
51✔
1157

1158
            if ($this->transDepth === 0) {
51✔
1159
                $this->transRollbackCallbacks = [];
50✔
1160
                $this->runTransCommitCallbacks();
50✔
1161
            }
1162

1163
            return true;
49✔
1164
        }
1165

1166
        return false;
1✔
1167
    }
1168

1169
    /**
1170
     * Rollback Transaction
1171
     */
1172
    public function transRollback(): bool
1173
    {
1174
        if (! $this->transEnabled || $this->transDepth === 0) {
34✔
UNCOV
1175
            return false;
×
1176
        }
1177

1178
        // When transactions are nested we only begin/commit/rollback the outermost ones
1179
        if ($this->transDepth > 1 || $this->_transRollback()) {
34✔
1180
            $this->transDepth--;
34✔
1181

1182
            if ($this->transDepth === 0) {
34✔
1183
                $this->transCommitCallbacks = [];
34✔
1184
                $this->runTransRollbackCallbacks();
34✔
1185
            }
1186

1187
            return true;
30✔
1188
        }
1189

1190
        return false;
1✔
1191
    }
1192

1193
    /**
1194
     * Reset transaction status - to restart transactions after strict mode failure
1195
     */
1196
    public function resetTransStatus(): static
1197
    {
1198
        $this->transStatus = true;
5✔
1199

1200
        return $this;
5✔
1201
    }
1202

1203
    /**
1204
     * Handle transaction status when a query fails
1205
     *
1206
     * @internal This method is for internal database component use only
1207
     */
1208
    public function handleTransStatus(): void
1209
    {
1210
        if ($this->transDepth !== 0) {
45✔
1211
            $this->transStatus = false;
21✔
1212
        }
1213
    }
1214

1215
    /**
1216
     * Run and clear callbacks registered for a successful transaction commit.
1217
     */
1218
    protected function runTransCommitCallbacks(): void
1219
    {
1220
        $callbacks                  = $this->transCommitCallbacks;
50✔
1221
        $this->transCommitCallbacks = [];
50✔
1222

1223
        foreach ($callbacks as $callback) {
50✔
1224
            $callback();
8✔
1225
        }
1226
    }
1227

1228
    /**
1229
     * Run and clear callbacks registered for a transaction rollback.
1230
     */
1231
    protected function runTransRollbackCallbacks(): void
1232
    {
1233
        $callbacks                    = $this->transRollbackCallbacks;
34✔
1234
        $this->transRollbackCallbacks = [];
34✔
1235

1236
        foreach ($callbacks as $callback) {
34✔
1237
            $callback();
10✔
1238
        }
1239
    }
1240

1241
    /**
1242
     * Begin Transaction
1243
     */
1244
    abstract protected function _transBegin(): bool;
1245

1246
    /**
1247
     * Commit Transaction
1248
     */
1249
    abstract protected function _transCommit(): bool;
1250

1251
    /**
1252
     * Rollback Transaction
1253
     */
1254
    abstract protected function _transRollback(): bool;
1255

1256
    /**
1257
     * Returns a non-shared new instance of the query builder for this connection.
1258
     *
1259
     * @param array|string|TableName $tableName
1260
     *
1261
     * @return BaseBuilder
1262
     *
1263
     * @throws DatabaseException
1264
     */
1265
    public function table($tableName)
1266
    {
1267
        if (empty($tableName)) {
986✔
UNCOV
1268
            throw new DatabaseException('You must set the database table to be used with your query.');
×
1269
        }
1270

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

1273
        return new $className($tableName, $this);
986✔
1274
    }
1275

1276
    /**
1277
     * Returns a new instance of the BaseBuilder class with a cleared FROM clause.
1278
     */
1279
    public function newQuery(): BaseBuilder
1280
    {
1281
        // save table aliases
1282
        $tempAliases         = $this->aliasedTables;
14✔
1283
        $builder             = $this->table(',')->from([], true);
14✔
1284
        $this->aliasedTables = $tempAliases;
14✔
1285

1286
        return $builder;
14✔
1287
    }
1288

1289
    /**
1290
     * Creates a prepared statement with the database that can then
1291
     * be used to execute multiple statements against. Within the
1292
     * closure, you would build the query in any normal way, though
1293
     * the Query Builder is the expected manner.
1294
     *
1295
     * Example:
1296
     *    $stmt = $db->prepare(function($db)
1297
     *           {
1298
     *             return $db->table('users')
1299
     *                   ->where('id', 1)
1300
     *                     ->get();
1301
     *           })
1302
     *
1303
     * @param Closure(BaseConnection): mixed $func
1304
     *
1305
     * @return BasePreparedQuery|null
1306
     */
1307
    public function prepare(Closure $func, array $options = [])
1308
    {
1309
        if (empty($this->connID)) {
16✔
UNCOV
1310
            $this->initialize();
×
1311
        }
1312

1313
        $this->pretend();
16✔
1314

1315
        $sql = $func($this);
16✔
1316

1317
        $this->pretend(false);
16✔
1318

1319
        if ($sql instanceof QueryInterface) {
16✔
1320
            $sql = $sql->getOriginalQuery();
16✔
1321
        }
1322

1323
        $class = str_ireplace('Connection', 'PreparedQuery', static::class);
16✔
1324
        /** @var BasePreparedQuery $class */
1325
        $class = new $class($this);
16✔
1326

1327
        return $class->prepare($sql, $options);
16✔
1328
    }
1329

1330
    /**
1331
     * Returns the last query's statement object.
1332
     *
1333
     * @return Query
1334
     */
1335
    public function getLastQuery()
1336
    {
1337
        return $this->lastQuery;
11✔
1338
    }
1339

1340
    /**
1341
     * Returns a string representation of the last query's statement object.
1342
     */
1343
    public function showLastQuery(): string
1344
    {
UNCOV
1345
        return (string) $this->lastQuery;
×
1346
    }
1347

1348
    /**
1349
     * Returns the time we started to connect to this database in
1350
     * seconds with microseconds.
1351
     *
1352
     * Used by the Debug Toolbar's timeline.
1353
     */
1354
    public function getConnectStart(): ?float
1355
    {
1356
        return $this->connectTime;
1✔
1357
    }
1358

1359
    /**
1360
     * Returns the number of seconds with microseconds that it took
1361
     * to connect to the database.
1362
     *
1363
     * Used by the Debug Toolbar's timeline.
1364
     */
1365
    public function getConnectDuration(int $decimals = 6): string
1366
    {
1367
        return number_format($this->connectDuration, $decimals);
2✔
1368
    }
1369

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

1403
        if (is_array($item)) {
1,149✔
1404
            $escapedArray = [];
1✔
1405

1406
            foreach ($item as $k => $v) {
1✔
1407
                $escapedArray[$this->protectIdentifiers($k)] = $this->protectIdentifiers($v, $prefixSingle, $protectIdentifiers, $fieldExists);
1✔
1408
            }
1409

1410
            return $escapedArray;
1✔
1411
        }
1412

1413
        if ($item instanceof TableName) {
1,149✔
1414
            /** @psalm-suppress NoValue I don't know why ERROR. */
1415
            return $this->escapeTableName($item);
2✔
1416
        }
1417

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

1421
        // This is basically a bug fix for queries that use MAX, MIN, etc.
1422
        // If a parenthesis is found we know that we do not need to
1423
        // escape the data or add a prefix. There's probably a more graceful
1424
        // way to deal with this, but I'm not thinking of it
1425
        //
1426
        // Added exception for single quotes as well, we don't want to alter
1427
        // literal strings.
1428
        if (strcspn($item, "()'") !== strlen($item)) {
1,149✔
1429
            /** @psalm-suppress NoValue I don't know why ERROR. */
1430
            return $item;
837✔
1431
        }
1432

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

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

1443
        // If the item has an alias declaration we remove it and set it aside.
1444
        // Note: strripos() is used in order to support spaces in table names
1445
        if ($offset = strripos($item, ' AS ')) {
1,137✔
1446
            $alias = ($protectIdentifiers) ? substr($item, $offset, 4) . $this->escapeIdentifiers(substr($item, $offset + 4)) : substr($item, $offset);
11✔
1447
            $item  = substr($item, 0, $offset);
11✔
1448
        } elseif ($offset = strrpos($item, ' ')) {
1,132✔
1449
            $alias = ($protectIdentifiers) ? ' ' . $this->escapeIdentifiers(substr($item, $offset + 1)) : substr($item, $offset);
12✔
1450
            $item  = substr($item, 0, $offset);
12✔
1451
        } else {
1452
            $alias = '';
1,126✔
1453
        }
1454

1455
        // Break the string apart if it contains periods, then insert the table prefix
1456
        // in the correct location, assuming the period doesn't indicate that we're dealing
1457
        // with an alias. While we're at it, we will escape the components
1458
        if (str_contains($item, '.')) {
1,137✔
1459
            return $this->protectDotItem($item, $alias, $protectIdentifiers, $fieldExists);
147✔
1460
        }
1461

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

1467
        // Is there a table prefix? If not, no need to insert it
1468
        if ($this->DBPrefix !== '') {
1,129✔
1469
            // Verify table prefix and replace if necessary
1470
            if ($this->swapPre !== '' && str_starts_with($item, $this->swapPre)) {
877✔
UNCOV
1471
                $item = preg_replace('/^' . $this->swapPre . '(\S+?)/', $this->DBPrefix . '\\1', $item);
×
1472
            }
1473
            // Do we prefix an item with no segments?
1474
            elseif ($prefixSingle && ! str_starts_with($item, $this->DBPrefix)) {
877✔
1475
                $item = $this->DBPrefix . $item;
870✔
1476
            }
1477
        }
1478

1479
        if ($protectIdentifiers === true && ! in_array($item, $this->reservedIdentifiers, true)) {
1,129✔
1480
            $item = $this->escapeIdentifiers($item);
1,127✔
1481
        }
1482

1483
        return $item . $alias;
1,129✔
1484
    }
1485

1486
    private function protectDotItem(string $item, string $alias, bool $protectIdentifiers, bool $fieldExists): string
1487
    {
1488
        $parts = explode('.', $item);
147✔
1489

1490
        // Does the first segment of the exploded item match
1491
        // one of the aliases previously identified? If so,
1492
        // we have nothing more to do other than escape the item
1493
        //
1494
        // NOTE: The ! empty() condition prevents this method
1495
        // from breaking when QB isn't enabled.
1496
        if (! empty($this->aliasedTables) && in_array($parts[0], $this->aliasedTables, true)) {
147✔
1497
            if ($protectIdentifiers) {
10✔
1498
                foreach ($parts as $key => $val) {
10✔
1499
                    if (! in_array($val, $this->reservedIdentifiers, true)) {
10✔
1500
                        $parts[$key] = $this->escapeIdentifiers($val);
10✔
1501
                    }
1502
                }
1503

1504
                $item = implode('.', $parts);
10✔
1505
            }
1506

1507
            return $item . $alias;
10✔
1508
        }
1509

1510
        // Is there a table prefix defined in the config file? If not, no need to do anything
1511
        if ($this->DBPrefix !== '') {
141✔
1512
            // We now add the table prefix based on some logic.
1513
            // Do we have 4 segments (hostname.database.table.column)?
1514
            // If so, we add the table prefix to the column name in the 3rd segment.
1515
            if (isset($parts[3])) {
133✔
UNCOV
1516
                $i = 2;
×
1517
            }
1518
            // Do we have 3 segments (database.table.column)?
1519
            // If so, we add the table prefix to the column name in 2nd position
1520
            elseif (isset($parts[2])) {
133✔
UNCOV
1521
                $i = 1;
×
1522
            }
1523
            // Do we have 2 segments (table.column)?
1524
            // If so, we add the table prefix to the column name in 1st segment
1525
            else {
1526
                $i = 0;
133✔
1527
            }
1528

1529
            // This flag is set when the supplied $item does not contain a field name.
1530
            // This can happen when this function is being called from a JOIN.
1531
            if ($fieldExists === false) {
133✔
UNCOV
1532
                $i++;
×
1533
            }
1534

1535
            // Verify table prefix and replace if necessary
1536
            if ($this->swapPre !== '' && str_starts_with($parts[$i], $this->swapPre)) {
133✔
UNCOV
1537
                $parts[$i] = preg_replace('/^' . $this->swapPre . '(\S+?)/', $this->DBPrefix . '\\1', $parts[$i]);
×
1538
            }
1539
            // We only add the table prefix if it does not already exist
1540
            elseif (! str_starts_with($parts[$i], $this->DBPrefix)) {
133✔
1541
                $parts[$i] = $this->DBPrefix . $parts[$i];
133✔
1542
            }
1543

1544
            // Put the parts back together
1545
            $item = implode('.', $parts);
133✔
1546
        }
1547

1548
        if ($protectIdentifiers) {
141✔
1549
            $item = $this->escapeIdentifiers($item);
141✔
1550
        }
1551

1552
        return $item . $alias;
141✔
1553
    }
1554

1555
    /**
1556
     * Escape the SQL Identifier
1557
     *
1558
     * This function escapes single identifier.
1559
     *
1560
     * @param non-empty-string|TableName $item
1561
     */
1562
    public function escapeIdentifier($item): string
1563
    {
1564
        if ($item === '') {
769✔
UNCOV
1565
            return '';
×
1566
        }
1567

1568
        if ($item instanceof TableName) {
769✔
1569
            return $this->escapeTableName($item);
7✔
1570
        }
1571

1572
        return $this->escapeChar
769✔
1573
            . str_replace(
769✔
1574
                $this->escapeChar,
769✔
1575
                $this->escapeChar . $this->escapeChar,
769✔
1576
                $item,
769✔
1577
            )
769✔
1578
            . $this->escapeChar;
769✔
1579
    }
1580

1581
    /**
1582
     * Returns escaped table name with alias.
1583
     */
1584
    private function escapeTableName(TableName $tableName): string
1585
    {
1586
        $alias = $tableName->getAlias();
7✔
1587

1588
        return $this->escapeIdentifier($tableName->getActualTableName())
7✔
1589
            . (($alias !== '') ? ' ' . $this->escapeIdentifier($alias) : '');
7✔
1590
    }
1591

1592
    /**
1593
     * Escape the SQL Identifiers
1594
     *
1595
     * This function escapes column and table names
1596
     *
1597
     * @param array|string $item
1598
     *
1599
     * @return ($item is array ? array : string)
1600
     */
1601
    public function escapeIdentifiers($item)
1602
    {
1603
        if ($this->escapeChar === '' || empty($item) || in_array($item, $this->reservedIdentifiers, true)) {
1,155✔
1604
            return $item;
5✔
1605
        }
1606

1607
        if (is_array($item)) {
1,154✔
1608
            foreach ($item as $key => $value) {
782✔
1609
                $item[$key] = $this->escapeIdentifiers($value);
782✔
1610
            }
1611

1612
            return $item;
782✔
1613
        }
1614

1615
        // Avoid breaking functions and literal values inside queries
1616
        if (ctype_digit($item)
1,154✔
1617
            || $item[0] === "'"
1,153✔
1618
            || ($this->escapeChar !== '"' && $item[0] === '"')
1,153✔
1619
            || str_contains($item, '(')) {
1,154✔
1620
            return $item;
47✔
1621
        }
1622

1623
        if ($this->pregEscapeChar === []) {
1,153✔
1624
            if (is_array($this->escapeChar)) {
328✔
UNCOV
1625
                $this->pregEscapeChar = [
×
UNCOV
1626
                    preg_quote($this->escapeChar[0], '/'),
×
UNCOV
1627
                    preg_quote($this->escapeChar[1], '/'),
×
UNCOV
1628
                    $this->escapeChar[0],
×
1629
                    $this->escapeChar[1],
×
1630
                ];
×
1631
            } else {
1632
                $this->pregEscapeChar[0] = $this->pregEscapeChar[1] = preg_quote($this->escapeChar, '/');
328✔
1633
                $this->pregEscapeChar[2] = $this->pregEscapeChar[3] = $this->escapeChar;
328✔
1634
            }
1635
        }
1636

1637
        foreach ($this->reservedIdentifiers as $id) {
1,153✔
1638
            /** @psalm-suppress NoValue I don't know why ERROR. */
1639
            if (str_contains($item, '.' . $id)) {
1,153✔
1640
                return preg_replace(
3✔
1641
                    '/' . $this->pregEscapeChar[0] . '?([^' . $this->pregEscapeChar[1] . '\.]+)' . $this->pregEscapeChar[1] . '?\./i',
3✔
1642
                    $this->pregEscapeChar[2] . '$1' . $this->pregEscapeChar[3] . '.',
3✔
1643
                    $item,
3✔
1644
                );
3✔
1645
            }
1646
        }
1647

1648
        /** @psalm-suppress NoValue I don't know why ERROR. */
1649
        return preg_replace(
1,151✔
1650
            '/' . $this->pregEscapeChar[0] . '?([^' . $this->pregEscapeChar[1] . '\.]+)' . $this->pregEscapeChar[1] . '?(\.)?/i',
1,151✔
1651
            $this->pregEscapeChar[2] . '$1' . $this->pregEscapeChar[3] . '$2',
1,151✔
1652
            $item,
1,151✔
1653
        );
1,151✔
1654
    }
1655

1656
    /**
1657
     * Prepends a database prefix if one exists in configuration
1658
     *
1659
     * @throws DatabaseException
1660
     */
1661
    public function prefixTable(string $table = ''): string
1662
    {
1663
        if ($table === '') {
3✔
UNCOV
1664
            throw new DatabaseException('A table name is required for that operation.');
×
1665
        }
1666

1667
        return $this->DBPrefix . $table;
3✔
1668
    }
1669

1670
    /**
1671
     * Returns the total number of rows affected by this query.
1672
     */
1673
    abstract public function affectedRows(): int;
1674

1675
    /**
1676
     * "Smart" Escape String
1677
     *
1678
     * Escapes data based on type.
1679
     * Sets boolean and null types
1680
     *
1681
     * @param array|bool|float|int|object|string|null $str
1682
     *
1683
     * @return ($str is array ? array : float|int|string)
1684
     */
1685
    public function escape($str)
1686
    {
1687
        if (is_array($str)) {
971✔
1688
            return array_map($this->escape(...), $str);
798✔
1689
        }
1690

1691
        if ($str instanceof Stringable) {
971✔
1692
            if ($str instanceof RawSql) {
13✔
1693
                return $str->__toString();
12✔
1694
            }
1695

1696
            $str = (string) $str;
1✔
1697
        }
1698

1699
        if (is_string($str)) {
968✔
1700
            return "'" . $this->escapeString($str) . "'";
917✔
1701
        }
1702

1703
        if (is_bool($str)) {
889✔
1704
            return ($str === false) ? 0 : 1;
8✔
1705
        }
1706

1707
        return $str ?? 'NULL';
887✔
1708
    }
1709

1710
    /**
1711
     * Escape String
1712
     *
1713
     * @param list<string|Stringable>|string|Stringable $str  Input string
1714
     * @param bool                                      $like Whether the string will be used in a LIKE condition
1715
     *
1716
     * @return list<string>|string
1717
     */
1718
    public function escapeString($str, bool $like = false)
1719
    {
1720
        if (is_array($str)) {
917✔
UNCOV
1721
            foreach ($str as $key => $val) {
×
UNCOV
1722
                $str[$key] = $this->escapeString($val, $like);
×
1723
            }
1724

1725
            return $str;
×
1726
        }
1727

1728
        if ($str instanceof Stringable) {
917✔
1729
            if ($str instanceof RawSql) {
2✔
UNCOV
1730
                return $str->__toString();
×
1731
            }
1732

1733
            $str = (string) $str;
2✔
1734
        }
1735

1736
        $str = $this->_escapeString($str);
917✔
1737

1738
        // escape LIKE condition wildcards
1739
        if ($like) {
917✔
1740
            return str_replace(
2✔
1741
                [
2✔
1742
                    $this->likeEscapeChar,
2✔
1743
                    '%',
2✔
1744
                    '_',
2✔
1745
                ],
2✔
1746
                [
2✔
1747
                    $this->likeEscapeChar . $this->likeEscapeChar,
2✔
1748
                    $this->likeEscapeChar . '%',
2✔
1749
                    $this->likeEscapeChar . '_',
2✔
1750
                ],
2✔
1751
                $str,
2✔
1752
            );
2✔
1753
        }
1754

1755
        return $str;
917✔
1756
    }
1757

1758
    /**
1759
     * Escape LIKE String
1760
     *
1761
     * Calls the individual driver for platform
1762
     * specific escaping for LIKE conditions
1763
     *
1764
     * @param list<string|Stringable>|string|Stringable $str
1765
     *
1766
     * @return list<string>|string
1767
     */
1768
    public function escapeLikeString($str)
1769
    {
1770
        return $this->escapeString($str, true);
2✔
1771
    }
1772

1773
    /**
1774
     * Platform independent string escape.
1775
     *
1776
     * Will likely be overridden in child classes.
1777
     */
1778
    protected function _escapeString(string $str): string
1779
    {
1780
        return str_replace("'", "''", remove_invisible_characters($str, false));
873✔
1781
    }
1782

1783
    /**
1784
     * This function enables you to call PHP database functions that are not natively included
1785
     * in CodeIgniter, in a platform independent manner.
1786
     *
1787
     * @param array ...$params
1788
     *
1789
     * @throws DatabaseException
1790
     */
1791
    public function callFunction(string $functionName, ...$params): bool
1792
    {
1793
        $driver = $this->getDriverFunctionPrefix();
2✔
1794

1795
        if (! str_starts_with($functionName, $driver)) {
2✔
1796
            $functionName = $driver . $functionName;
1✔
1797
        }
1798

1799
        if (! function_exists($functionName)) {
2✔
UNCOV
1800
            if ($this->DBDebug) {
×
UNCOV
1801
                throw new DatabaseException('This feature is not available for the database you are using.');
×
1802
            }
1803

1804
            return false;
×
1805
        }
1806

1807
        return $functionName(...$params);
2✔
1808
    }
1809

1810
    /**
1811
     * Get the prefix of the function to access the DB.
1812
     */
1813
    protected function getDriverFunctionPrefix(): string
1814
    {
UNCOV
1815
        return strtolower($this->DBDriver) . '_';
×
1816
    }
1817

1818
    // --------------------------------------------------------------------
1819
    // META Methods
1820
    // --------------------------------------------------------------------
1821

1822
    /**
1823
     * Returns an array of table names
1824
     *
1825
     * @return false|list<string>
1826
     *
1827
     * @throws DatabaseException
1828
     */
1829
    public function listTables(bool $constrainByPrefix = false)
1830
    {
1831
        if (isset($this->dataCache['table_names']) && $this->dataCache['table_names']) {
830✔
1832
            return $constrainByPrefix
824✔
1833
                ? preg_grep("/^{$this->DBPrefix}/", $this->dataCache['table_names'])
2✔
1834
                : $this->dataCache['table_names'];
824✔
1835
        }
1836

1837
        $sql = $this->_listTables($constrainByPrefix);
84✔
1838

1839
        if ($sql === false) {
84✔
UNCOV
1840
            if ($this->DBDebug) {
×
UNCOV
1841
                throw new DatabaseException('This feature is not available for the database you are using.');
×
1842
            }
1843

1844
            return false;
×
1845
        }
1846

1847
        $this->dataCache['table_names'] = [];
84✔
1848

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

1851
        foreach ($query->getResultArray() as $row) {
84✔
1852
            /** @var string $table */
1853
            $table = $row['table_name'] ?? $row['TABLE_NAME'] ?? $row[array_key_first($row)];
81✔
1854

1855
            $this->dataCache['table_names'][] = $table;
81✔
1856
        }
1857

1858
        return $this->dataCache['table_names'];
84✔
1859
    }
1860

1861
    /**
1862
     * Determine if a particular table exists
1863
     *
1864
     * @param bool $cached Whether to use data cache
1865
     */
1866
    public function tableExists(string $tableName, bool $cached = true): bool
1867
    {
1868
        if ($cached) {
824✔
1869
            return in_array($this->protectIdentifiers($tableName, true, false, false), $this->listTables(), true);
823✔
1870
        }
1871

1872
        if (false === ($sql = $this->_listTables(false, $tableName))) {
777✔
UNCOV
1873
            if ($this->DBDebug) {
×
UNCOV
1874
                throw new DatabaseException('This feature is not available for the database you are using.');
×
1875
            }
1876

1877
            return false;
×
1878
        }
1879

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

1882
        // if cache has been built already
1883
        if (! empty($this->dataCache['table_names'])) {
777✔
1884
            $key = array_search(
773✔
1885
                strtolower($tableName),
773✔
1886
                array_map(strtolower(...), $this->dataCache['table_names']),
773✔
1887
                true,
773✔
1888
            );
773✔
1889

1890
            // table doesn't exist but still in cache - lets reset cache, it can be rebuilt later
1891
            // OR if table does exist but is not found in cache
1892
            if (($key !== false && ! $tableExists) || ($key === false && $tableExists)) {
773✔
1893
                $this->resetDataCache();
1✔
1894
            }
1895
        }
1896

1897
        return $tableExists;
777✔
1898
    }
1899

1900
    /**
1901
     * Fetch Field Names
1902
     *
1903
     * @param string|TableName $tableName
1904
     *
1905
     * @return false|list<string>
1906
     *
1907
     * @throws DatabaseException
1908
     */
1909
    public function getFieldNames($tableName)
1910
    {
1911
        $table = ($tableName instanceof TableName) ? $tableName->getTableName() : $tableName;
12✔
1912

1913
        // Is there a cached result?
1914
        if (isset($this->dataCache['field_names'][$table])) {
12✔
1915
            return $this->dataCache['field_names'][$table];
7✔
1916
        }
1917

1918
        if (empty($this->connID)) {
8✔
UNCOV
1919
            $this->initialize();
×
1920
        }
1921

1922
        if (false === ($sql = $this->_listColumns($tableName))) {
8✔
1923
            if ($this->DBDebug) {
×
UNCOV
1924
                throw new DatabaseException('This feature is not available for the database you are using.');
×
1925
            }
1926

1927
            return false;
×
1928
        }
1929

1930
        $query = $this->query($sql);
8✔
1931

1932
        $this->dataCache['field_names'][$table] = [];
8✔
1933

1934
        foreach ($query->getResultArray() as $row) {
8✔
1935
            // Do we know from where to get the column's name?
1936
            if (! isset($key)) {
8✔
1937
                if (isset($row['column_name'])) {
8✔
1938
                    $key = 'column_name';
8✔
1939
                } elseif (isset($row['COLUMN_NAME'])) {
8✔
1940
                    $key = 'COLUMN_NAME';
8✔
1941
                } else {
1942
                    // We have no other choice but to just get the first element's key.
1943
                    $key = key($row);
8✔
1944
                }
1945
            }
1946

1947
            $this->dataCache['field_names'][$table][] = $row[$key];
8✔
1948
        }
1949

1950
        return $this->dataCache['field_names'][$table];
8✔
1951
    }
1952

1953
    /**
1954
     * Determine if a particular field exists
1955
     */
1956
    public function fieldExists(string $fieldName, string $tableName): bool
1957
    {
1958
        return in_array($fieldName, $this->getFieldNames($tableName), true);
8✔
1959
    }
1960

1961
    /**
1962
     * Returns an object with field data
1963
     *
1964
     * @return list<stdClass>
1965
     */
1966
    public function getFieldData(string $table)
1967
    {
1968
        return $this->_fieldData($this->protectIdentifiers($table, true, false, false));
150✔
1969
    }
1970

1971
    /**
1972
     * Returns an object with key data
1973
     *
1974
     * @return array<string, stdClass>
1975
     */
1976
    public function getIndexData(string $table)
1977
    {
1978
        return $this->_indexData($this->protectIdentifiers($table, true, false, false));
165✔
1979
    }
1980

1981
    /**
1982
     * Returns an object with foreign key data
1983
     *
1984
     * @return array<string, stdClass>
1985
     */
1986
    public function getForeignKeyData(string $table)
1987
    {
1988
        return $this->_foreignKeyData($this->protectIdentifiers($table, true, false, false));
37✔
1989
    }
1990

1991
    /**
1992
     * Converts array of arrays generated by _foreignKeyData() to array of objects
1993
     *
1994
     * @return array<string, stdClass>
1995
     *
1996
     * array[
1997
     *    {constraint_name} =>
1998
     *        stdClass[
1999
     *            'constraint_name'     => string,
2000
     *            'table_name'          => string,
2001
     *            'column_name'         => string[],
2002
     *            'foreign_table_name'  => string,
2003
     *            'foreign_column_name' => string[],
2004
     *            'on_delete'           => string,
2005
     *            'on_update'           => string,
2006
     *            'match'               => string
2007
     *        ]
2008
     * ]
2009
     */
2010
    protected function foreignKeyDataToObjects(array $data)
2011
    {
2012
        $retVal = [];
37✔
2013

2014
        foreach ($data as $row) {
37✔
2015
            $name = $row['constraint_name'];
12✔
2016

2017
            // for sqlite generate name
2018
            if ($name === null) {
12✔
2019
                $name = $row['table_name'] . '_' . implode('_', $row['column_name']) . '_foreign';
11✔
2020
            }
2021

2022
            $obj                      = new stdClass();
12✔
2023
            $obj->constraint_name     = $name;
12✔
2024
            $obj->table_name          = $row['table_name'];
12✔
2025
            $obj->column_name         = $row['column_name'];
12✔
2026
            $obj->foreign_table_name  = $row['foreign_table_name'];
12✔
2027
            $obj->foreign_column_name = $row['foreign_column_name'];
12✔
2028
            $obj->on_delete           = $row['on_delete'];
12✔
2029
            $obj->on_update           = $row['on_update'];
12✔
2030
            $obj->match               = $row['match'];
12✔
2031

2032
            $retVal[$name] = $obj;
12✔
2033
        }
2034

2035
        return $retVal;
37✔
2036
    }
2037

2038
    /**
2039
     * Disables foreign key checks temporarily.
2040
     *
2041
     * @return bool
2042
     */
2043
    public function disableForeignKeyChecks()
2044
    {
2045
        $sql = $this->_disableForeignKeyChecks();
791✔
2046

2047
        if ($sql === '') {
791✔
2048
            // The feature is not supported.
UNCOV
2049
            return false;
×
2050
        }
2051

2052
        return $this->query($sql);
791✔
2053
    }
2054

2055
    /**
2056
     * Enables foreign key checks temporarily.
2057
     *
2058
     * @return bool
2059
     */
2060
    public function enableForeignKeyChecks()
2061
    {
2062
        $sql = $this->_enableForeignKeyChecks();
874✔
2063

2064
        if ($sql === '') {
874✔
2065
            // The feature is not supported.
UNCOV
2066
            return false;
×
2067
        }
2068

2069
        return $this->query($sql);
874✔
2070
    }
2071

2072
    /**
2073
     * Allows the engine to be set into a mode where queries are not
2074
     * actually executed, but they are still generated, timed, etc.
2075
     *
2076
     * This is primarily used by the prepared query functionality.
2077
     *
2078
     * @return $this
2079
     */
2080
    public function pretend(bool $pretend = true)
2081
    {
2082
        $this->pretend = $pretend;
17✔
2083

2084
        return $this;
17✔
2085
    }
2086

2087
    /**
2088
     * Empties our data cache. Especially helpful during testing.
2089
     *
2090
     * @return $this
2091
     */
2092
    public function resetDataCache()
2093
    {
2094
        $this->dataCache = [];
36✔
2095

2096
        return $this;
36✔
2097
    }
2098

2099
    /**
2100
     * Determines if the statement is a write-type query or not.
2101
     *
2102
     * @param string $sql
2103
     */
2104
    public function isWriteType($sql): bool
2105
    {
2106
        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✔
2107
    }
2108

2109
    /**
2110
     * Returns the last error code and message.
2111
     *
2112
     * Must return an array with keys 'code' and 'message':
2113
     *
2114
     * @return array{code: int|string|null, message: string|null}
2115
     */
2116
    abstract public function error(): array;
2117

2118
    /**
2119
     * Returns the exception that would have been thrown on the last failed
2120
     * query if DBDebug were enabled. Returns null if the last query succeeded
2121
     * or if DBDebug is true (in which case the exception is always thrown
2122
     * directly and this method will always return null).
2123
     */
2124
    public function getLastException(): ?DatabaseException
2125
    {
2126
        return $this->lastException;
4✔
2127
    }
2128

2129
    /**
2130
     * Insert ID
2131
     *
2132
     * @return int|string
2133
     */
2134
    abstract public function insertID();
2135

2136
    /**
2137
     * Generates the SQL for listing tables in a platform-dependent manner.
2138
     *
2139
     * @param string|null $tableName If $tableName is provided will return only this table if exists.
2140
     *
2141
     * @return false|string
2142
     */
2143
    abstract protected function _listTables(bool $constrainByPrefix = false, ?string $tableName = null);
2144

2145
    /**
2146
     * Generates a platform-specific query string so that the column names can be fetched.
2147
     *
2148
     * @param string|TableName $table
2149
     *
2150
     * @return false|string
2151
     */
2152
    abstract protected function _listColumns($table = '');
2153

2154
    /**
2155
     * Platform-specific field data information.
2156
     *
2157
     * @see getFieldData()
2158
     *
2159
     * @return list<stdClass>
2160
     */
2161
    abstract protected function _fieldData(string $table): array;
2162

2163
    /**
2164
     * Platform-specific index data.
2165
     *
2166
     * @see    getIndexData()
2167
     *
2168
     * @return array<string, stdClass>
2169
     */
2170
    abstract protected function _indexData(string $table): array;
2171

2172
    /**
2173
     * Platform-specific foreign keys data.
2174
     *
2175
     * @see    getForeignKeyData()
2176
     *
2177
     * @return array<string, stdClass>
2178
     */
2179
    abstract protected function _foreignKeyData(string $table): array;
2180

2181
    /**
2182
     * Platform-specific SQL statement to disable foreign key checks.
2183
     *
2184
     * If this feature is not supported, return empty string.
2185
     *
2186
     * @TODO This method should be moved to an interface that represents foreign key support.
2187
     *
2188
     * @return string
2189
     *
2190
     * @see disableForeignKeyChecks()
2191
     */
2192
    protected function _disableForeignKeyChecks()
2193
    {
UNCOV
2194
        return '';
×
2195
    }
2196

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

2213
    /**
2214
     * Converts a named timezone to an offset string.
2215
     *
2216
     * Converts timezone identifiers (e.g., 'America/New_York') to offset strings
2217
     * (e.g., '-05:00' or '-04:00' depending on DST). This is useful because not all
2218
     * databases have timezone tables loaded, but all support offset notation.
2219
     *
2220
     * @param string $timezone Named timezone (e.g., 'America/New_York', 'UTC', 'Europe/Paris')
2221
     *
2222
     * @return string Offset string (e.g., '+00:00', '-05:00', '+01:00')
2223
     */
2224
    protected function convertTimezoneToOffset(string $timezone): string
2225
    {
2226
        // If it's already an offset, return as-is
2227
        if (preg_match('/^[+-]\d{2}:\d{2}$/', $timezone)) {
9✔
2228
            return $timezone;
3✔
2229
        }
2230

2231
        try {
2232
            $offset = Time::now($timezone)->getOffset();
6✔
2233

2234
            // Convert offset seconds to +-HH:MM format
2235
            $hours   = (int) ($offset / 3600);
5✔
2236
            $minutes = abs((int) (($offset % 3600) / 60));
5✔
2237

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

2243
            return '+00:00';
1✔
2244
        }
2245
    }
2246

2247
    /**
2248
     * Gets the timezone string to use for database session.
2249
     *
2250
     * Handles the timezone configuration logic:
2251
     * - false: Don't set timezone (returns null)
2252
     * - true: Auto-sync with app timezone from config
2253
     * - string: Use specific timezone (converts named timezones to offsets)
2254
     *
2255
     * @return string|null The timezone offset string, or null if timezone should not be set
2256
     */
2257
    protected function getSessionTimezone(): ?string
2258
    {
2259
        if ($this->timezone === false) {
68✔
2260
            return null;
62✔
2261
        }
2262

2263
        // Auto-sync with app timezone
2264
        if ($this->timezone === true) {
6✔
2265
            $appConfig = config('App');
2✔
2266
            $timezone  = $appConfig->appTimezone;
2✔
2267
        } else {
2268
            // Use specific timezone from config
2269
            $timezone = $this->timezone;
4✔
2270
        }
2271

2272
        return $this->convertTimezoneToOffset($timezone);
6✔
2273
    }
2274

2275
    /**
2276
     * Accessor for properties if they exist.
2277
     *
2278
     * @return array|bool|float|int|object|resource|string|null
2279
     */
2280
    public function __get(string $key)
2281
    {
2282
        if (property_exists($this, $key)) {
1,128✔
2283
            return $this->{$key};
1,127✔
2284
        }
2285

2286
        return null;
1✔
2287
    }
2288

2289
    /**
2290
     * Checker for properties existence.
2291
     */
2292
    public function __isset(string $key): bool
2293
    {
2294
        return property_exists($this, $key);
250✔
2295
    }
2296
}
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