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

codeigniter4 / CodeIgniter4 / 25167782432

30 Apr 2026 01:21PM UTC coverage: 88.358% (+0.02%) from 88.343%
25167782432

Pull #10148

github

web-flow
Merge 1f2f32685 into 466d0f7e4
Pull Request #10148: feat(database): add closure transaction helper

16 of 17 new or added lines in 1 file covered. (94.12%)

38 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
     * @param callable(self): mixed $callback
1073
     */
1074
    public function transaction(callable $callback): mixed
1075
    {
1076
        if (! $this->transEnabled) {
14✔
1077
            return $callback($this);
1✔
1078
        }
1079

1080
        if (! $this->transBegin()) {
13✔
1081
            return false;
1✔
1082
        }
1083

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

1097
            throw $e;
4✔
1098
        }
1099

1100
        if (! $this->transComplete()) {
7✔
1101
            return false;
1✔
1102
        }
1103

1104
        return $result;
5✔
1105
    }
1106

1107
    /**
1108
     * Begin Transaction
1109
     */
1110
    public function transBegin(bool $testMode = false): bool
1111
    {
1112
        if (! $this->transEnabled) {
80✔
NEW
1113
            return false;
×
1114
        }
1115

1116
        // When transactions are nested we only begin/commit/rollback the outermost ones
1117
        if ($this->transDepth > 0) {
80✔
1118
            $this->transDepth++;
5✔
1119

1120
            return true;
5✔
1121
        }
1122

1123
        if (empty($this->connID)) {
80✔
1124
            $this->initialize();
3✔
1125
        }
1126

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

1132
        if ($this->_transBegin()) {
80✔
1133
            $this->transDepth++;
79✔
1134

1135
            return true;
79✔
1136
        }
1137

1138
        return false;
1✔
1139
    }
1140

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

1150
        // When transactions are nested we only begin/commit/rollback the outermost ones
1151
        if ($this->transDepth > 1 || $this->_transCommit()) {
51✔
1152
            $this->transDepth--;
51✔
1153

1154
            if ($this->transDepth === 0) {
51✔
1155
                $this->transRollbackCallbacks = [];
50✔
1156
                $this->runTransCommitCallbacks();
50✔
1157
            }
1158

1159
            return true;
49✔
1160
        }
1161

1162
        return false;
1✔
1163
    }
1164

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

1174
        // When transactions are nested we only begin/commit/rollback the outermost ones
1175
        if ($this->transDepth > 1 || $this->_transRollback()) {
34✔
1176
            $this->transDepth--;
34✔
1177

1178
            if ($this->transDepth === 0) {
34✔
1179
                $this->transCommitCallbacks = [];
34✔
1180
                $this->runTransRollbackCallbacks();
34✔
1181
            }
1182

1183
            return true;
30✔
1184
        }
1185

1186
        return false;
1✔
1187
    }
1188

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

1196
        return $this;
5✔
1197
    }
1198

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

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

1219
        foreach ($callbacks as $callback) {
50✔
1220
            $callback();
8✔
1221
        }
1222
    }
1223

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

1232
        foreach ($callbacks as $callback) {
34✔
1233
            $callback();
10✔
1234
        }
1235
    }
1236

1237
    /**
1238
     * Begin Transaction
1239
     */
1240
    abstract protected function _transBegin(): bool;
1241

1242
    /**
1243
     * Commit Transaction
1244
     */
1245
    abstract protected function _transCommit(): bool;
1246

1247
    /**
1248
     * Rollback Transaction
1249
     */
1250
    abstract protected function _transRollback(): bool;
1251

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

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

1269
        return new $className($tableName, $this);
986✔
1270
    }
1271

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

1282
        return $builder;
14✔
1283
    }
1284

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

1309
        $this->pretend();
16✔
1310

1311
        $sql = $func($this);
16✔
1312

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

1315
        if ($sql instanceof QueryInterface) {
16✔
1316
            $sql = $sql->getOriginalQuery();
16✔
1317
        }
1318

1319
        $class = str_ireplace('Connection', 'PreparedQuery', static::class);
16✔
1320
        /** @var BasePreparedQuery $class */
1321
        $class = new $class($this);
16✔
1322

1323
        return $class->prepare($sql, $options);
16✔
1324
    }
1325

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

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

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

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

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

1399
        if (is_array($item)) {
1,149✔
1400
            $escapedArray = [];
1✔
1401

1402
            foreach ($item as $k => $v) {
1✔
1403
                $escapedArray[$this->protectIdentifiers($k)] = $this->protectIdentifiers($v, $prefixSingle, $protectIdentifiers, $fieldExists);
1✔
1404
            }
1405

1406
            return $escapedArray;
1✔
1407
        }
1408

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

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

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

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

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

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

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

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

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

1475
        if ($protectIdentifiers === true && ! in_array($item, $this->reservedIdentifiers, true)) {
1,129✔
1476
            $item = $this->escapeIdentifiers($item);
1,127✔
1477
        }
1478

1479
        return $item . $alias;
1,129✔
1480
    }
1481

1482
    private function protectDotItem(string $item, string $alias, bool $protectIdentifiers, bool $fieldExists): string
1483
    {
1484
        $parts = explode('.', $item);
147✔
1485

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

1500
                $item = implode('.', $parts);
10✔
1501
            }
1502

1503
            return $item . $alias;
10✔
1504
        }
1505

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

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

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

1540
            // Put the parts back together
1541
            $item = implode('.', $parts);
133✔
1542
        }
1543

1544
        if ($protectIdentifiers) {
141✔
1545
            $item = $this->escapeIdentifiers($item);
141✔
1546
        }
1547

1548
        return $item . $alias;
141✔
1549
    }
1550

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

1564
        if ($item instanceof TableName) {
769✔
1565
            return $this->escapeTableName($item);
7✔
1566
        }
1567

1568
        return $this->escapeChar
769✔
1569
            . str_replace(
769✔
1570
                $this->escapeChar,
769✔
1571
                $this->escapeChar . $this->escapeChar,
769✔
1572
                $item,
769✔
1573
            )
769✔
1574
            . $this->escapeChar;
769✔
1575
    }
1576

1577
    /**
1578
     * Returns escaped table name with alias.
1579
     */
1580
    private function escapeTableName(TableName $tableName): string
1581
    {
1582
        $alias = $tableName->getAlias();
7✔
1583

1584
        return $this->escapeIdentifier($tableName->getActualTableName())
7✔
1585
            . (($alias !== '') ? ' ' . $this->escapeIdentifier($alias) : '');
7✔
1586
    }
1587

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

1603
        if (is_array($item)) {
1,154✔
1604
            foreach ($item as $key => $value) {
782✔
1605
                $item[$key] = $this->escapeIdentifiers($value);
782✔
1606
            }
1607

1608
            return $item;
782✔
1609
        }
1610

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

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

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

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

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

1663
        return $this->DBPrefix . $table;
3✔
1664
    }
1665

1666
    /**
1667
     * Returns the total number of rows affected by this query.
1668
     */
1669
    abstract public function affectedRows(): int;
1670

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

1687
        if ($str instanceof Stringable) {
971✔
1688
            if ($str instanceof RawSql) {
13✔
1689
                return $str->__toString();
12✔
1690
            }
1691

1692
            $str = (string) $str;
1✔
1693
        }
1694

1695
        if (is_string($str)) {
968✔
1696
            return "'" . $this->escapeString($str) . "'";
917✔
1697
        }
1698

1699
        if (is_bool($str)) {
889✔
1700
            return ($str === false) ? 0 : 1;
8✔
1701
        }
1702

1703
        return $str ?? 'NULL';
887✔
1704
    }
1705

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

UNCOV
1721
            return $str;
×
1722
        }
1723

1724
        if ($str instanceof Stringable) {
917✔
1725
            if ($str instanceof RawSql) {
2✔
1726
                return $str->__toString();
×
1727
            }
1728

1729
            $str = (string) $str;
2✔
1730
        }
1731

1732
        $str = $this->_escapeString($str);
917✔
1733

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

1751
        return $str;
917✔
1752
    }
1753

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

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

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

1791
        if (! str_starts_with($functionName, $driver)) {
2✔
1792
            $functionName = $driver . $functionName;
1✔
1793
        }
1794

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

UNCOV
1800
            return false;
×
1801
        }
1802

1803
        return $functionName(...$params);
2✔
1804
    }
1805

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

1814
    // --------------------------------------------------------------------
1815
    // META Methods
1816
    // --------------------------------------------------------------------
1817

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

1833
        $sql = $this->_listTables($constrainByPrefix);
84✔
1834

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

UNCOV
1840
            return false;
×
1841
        }
1842

1843
        $this->dataCache['table_names'] = [];
84✔
1844

1845
        $query = $this->query($sql);
84✔
1846

1847
        foreach ($query->getResultArray() as $row) {
84✔
1848
            /** @var string $table */
1849
            $table = $row['table_name'] ?? $row['TABLE_NAME'] ?? $row[array_key_first($row)];
81✔
1850

1851
            $this->dataCache['table_names'][] = $table;
81✔
1852
        }
1853

1854
        return $this->dataCache['table_names'];
84✔
1855
    }
1856

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

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

UNCOV
1873
            return false;
×
1874
        }
1875

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

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

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

1893
        return $tableExists;
777✔
1894
    }
1895

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

1909
        // Is there a cached result?
1910
        if (isset($this->dataCache['field_names'][$table])) {
12✔
1911
            return $this->dataCache['field_names'][$table];
7✔
1912
        }
1913

1914
        if (empty($this->connID)) {
8✔
UNCOV
1915
            $this->initialize();
×
1916
        }
1917

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

1923
            return false;
×
1924
        }
1925

1926
        $query = $this->query($sql);
8✔
1927

1928
        $this->dataCache['field_names'][$table] = [];
8✔
1929

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

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

1946
        return $this->dataCache['field_names'][$table];
8✔
1947
    }
1948

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

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

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

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

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

2010
        foreach ($data as $row) {
37✔
2011
            $name = $row['constraint_name'];
12✔
2012

2013
            // for sqlite generate name
2014
            if ($name === null) {
12✔
2015
                $name = $row['table_name'] . '_' . implode('_', $row['column_name']) . '_foreign';
11✔
2016
            }
2017

2018
            $obj                      = new stdClass();
12✔
2019
            $obj->constraint_name     = $name;
12✔
2020
            $obj->table_name          = $row['table_name'];
12✔
2021
            $obj->column_name         = $row['column_name'];
12✔
2022
            $obj->foreign_table_name  = $row['foreign_table_name'];
12✔
2023
            $obj->foreign_column_name = $row['foreign_column_name'];
12✔
2024
            $obj->on_delete           = $row['on_delete'];
12✔
2025
            $obj->on_update           = $row['on_update'];
12✔
2026
            $obj->match               = $row['match'];
12✔
2027

2028
            $retVal[$name] = $obj;
12✔
2029
        }
2030

2031
        return $retVal;
37✔
2032
    }
2033

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

2043
        if ($sql === '') {
791✔
2044
            // The feature is not supported.
UNCOV
2045
            return false;
×
2046
        }
2047

2048
        return $this->query($sql);
791✔
2049
    }
2050

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

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

2065
        return $this->query($sql);
874✔
2066
    }
2067

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

2080
        return $this;
17✔
2081
    }
2082

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

2092
        return $this;
36✔
2093
    }
2094

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

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

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

2125
    /**
2126
     * Insert ID
2127
     *
2128
     * @return int|string
2129
     */
2130
    abstract public function insertID();
2131

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

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

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

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

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

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

2193
    /**
2194
     * Platform-specific SQL statement to enable 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 enableForeignKeyChecks()
2203
     */
2204
    protected function _enableForeignKeyChecks()
2205
    {
UNCOV
2206
        return '';
×
2207
    }
2208

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

2227
        try {
2228
            $offset = Time::now($timezone)->getOffset();
6✔
2229

2230
            // Convert offset seconds to +-HH:MM format
2231
            $hours   = (int) ($offset / 3600);
5✔
2232
            $minutes = abs((int) (($offset % 3600) / 60));
5✔
2233

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

2239
            return '+00:00';
1✔
2240
        }
2241
    }
2242

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

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

2268
        return $this->convertTimezoneToOffset($timezone);
6✔
2269
    }
2270

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

2282
        return null;
1✔
2283
    }
2284

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