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

codeigniter4 / CodeIgniter4 / 26026282147

18 May 2026 09:53AM UTC coverage: 88.479% (+0.2%) from 88.299%
26026282147

Pull #10159

github

web-flow
Merge 45305d77b into 37b8b37b5
Pull Request #10159: feat: Add support for callable TTLs in cache handlers

6 of 10 new or added lines in 3 files covered. (60.0%)

452 existing lines in 24 files now uncovered.

24160 of 27306 relevant lines covered (88.48%)

219.61 hits per line

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

91.26
/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\Database\Exceptions\RetryableTransactionException;
19
use CodeIgniter\Database\Exceptions\UniqueConstraintViolationException;
20
use CodeIgniter\Events\Events;
21
use CodeIgniter\Exceptions\InvalidArgumentException;
22
use CodeIgniter\I18n\Time;
23
use Exception;
24
use ReflectionClass;
25
use ReflectionNamedType;
26
use ReflectionType;
27
use ReflectionUnionType;
28
use stdClass;
29
use Stringable;
30
use Throwable;
31

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

77
    /**
78
     * Data Source Name / Connect string
79
     *
80
     * @var string
81
     */
82
    protected $DSN;
83

84
    /**
85
     * Database port
86
     *
87
     * @var int|string
88
     */
89
    protected $port = '';
90

91
    /**
92
     * Hostname
93
     *
94
     * @var string
95
     */
96
    protected $hostname;
97

98
    /**
99
     * Username
100
     *
101
     * @var string
102
     */
103
    protected $username;
104

105
    /**
106
     * Password
107
     *
108
     * @var string
109
     */
110
    protected $password;
111

112
    /**
113
     * Database name
114
     *
115
     * @var string
116
     */
117
    protected $database;
118

119
    /**
120
     * Database driver
121
     *
122
     * @var string
123
     */
124
    protected $DBDriver = 'MySQLi';
125

126
    /**
127
     * Sub-driver
128
     *
129
     * @used-by CI_DB_pdo_driver
130
     *
131
     * @var string
132
     */
133
    protected $subdriver;
134

135
    /**
136
     * Table prefix
137
     *
138
     * @var string
139
     */
140
    protected $DBPrefix = '';
141

142
    /**
143
     * Persistent connection flag
144
     *
145
     * @var bool
146
     */
147
    protected $pConnect = false;
148

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

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

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

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

188
    /**
189
     * Swap Prefix
190
     *
191
     * @var string
192
     */
193
    protected $swapPre = '';
194

195
    /**
196
     * Encryption flag/data
197
     *
198
     * @var array|bool
199
     */
200
    protected $encrypt = false;
201

202
    /**
203
     * Compression flag
204
     *
205
     * @var bool
206
     */
207
    protected $compress = false;
208

209
    /**
210
     * Settings for a failover connection.
211
     *
212
     * @var array
213
     */
214
    protected $failover = [];
215

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

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

232
    /**
233
     * The first database exception that caused the current transaction to fail.
234
     */
235
    protected ?DatabaseException $transFailureException = null;
236

237
    /**
238
     * Connection ID
239
     *
240
     * @var false|TConnection
241
     */
242
    public $connID = false;
243

244
    /**
245
     * Result ID
246
     *
247
     * @var false|TResult
248
     */
249
    public $resultID = false;
250

251
    /**
252
     * Protect identifiers flag
253
     *
254
     * @var bool
255
     */
256
    public $protectIdentifiers = true;
257

258
    /**
259
     * List of reserved identifiers
260
     *
261
     * Identifiers that must NOT be escaped.
262
     *
263
     * @var array
264
     */
265
    protected $reservedIdentifiers = ['*'];
266

267
    /**
268
     * Identifier escape character
269
     *
270
     * @var array|string
271
     */
272
    public $escapeChar = '"';
273

274
    /**
275
     * ESCAPE statement string
276
     *
277
     * @var string
278
     */
279
    public $likeEscapeStr = " ESCAPE '%s' ";
280

281
    /**
282
     * ESCAPE character
283
     *
284
     * @var string
285
     */
286
    public $likeEscapeChar = '!';
287

288
    /**
289
     * RegExp used to escape identifiers
290
     *
291
     * @var array
292
     */
293
    protected $pregEscapeChar = [];
294

295
    /**
296
     * Holds previously looked up data
297
     * for performance reasons.
298
     *
299
     * @var array
300
     */
301
    public $dataCache = [];
302

303
    /**
304
     * Microtime when connection was made
305
     *
306
     * @var float
307
     */
308
    protected $connectTime = 0.0;
309

310
    /**
311
     * How long it took to establish connection.
312
     *
313
     * @var float
314
     */
315
    protected $connectDuration = 0.0;
316

317
    /**
318
     * If true, no queries will actually be
319
     * run against the database.
320
     *
321
     * @var bool
322
     */
323
    protected $pretend = false;
324

325
    /**
326
     * Transaction enabled flag
327
     *
328
     * @var bool
329
     */
330
    public $transEnabled = true;
331

332
    /**
333
     * Strict transaction mode flag
334
     *
335
     * @var bool
336
     */
337
    public $transStrict = true;
338

339
    /**
340
     * Transaction depth level
341
     *
342
     * @var int
343
     */
344
    protected $transDepth = 0;
345

346
    /**
347
     * Transaction status flag
348
     *
349
     * Used with transactions to determine if a rollback should occur.
350
     *
351
     * @var bool
352
     */
353
    protected $transStatus = true;
354

355
    /**
356
     * Transaction failure flag
357
     *
358
     * Used with transactions to determine if a transaction has failed.
359
     *
360
     * @var bool
361
     */
362
    protected $transFailure = false;
363

364
    /**
365
     * Whether to throw exceptions during transaction
366
     */
367
    protected bool $transException = false;
368

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

376
    /**
377
     * Callbacks to run after the outermost transaction rolls back.
378
     *
379
     * @var list<callable(): void>
380
     */
381
    protected array $transRollbackCallbacks = [];
382

383
    /**
384
     * Array of table aliases.
385
     *
386
     * @var list<string>
387
     */
388
    protected $aliasedTables = [];
389

390
    /**
391
     * Query Class
392
     *
393
     * @var string
394
     */
395
    protected $queryClass = Query::class;
396

397
    /**
398
     * Default Date/Time formats
399
     *
400
     * @var array<string, string>
401
     */
402
    protected array $dateFormat = [
403
        'date'        => 'Y-m-d',
404
        'datetime'    => 'Y-m-d H:i:s',
405
        'datetime-ms' => 'Y-m-d H:i:s.v',
406
        'datetime-us' => 'Y-m-d H:i:s.u',
407
        'time'        => 'H:i:s',
408
    ];
409

410
    /**
411
     * Saves our connection settings.
412
     */
413
    public function __construct(array $params)
414
    {
415
        if (isset($params['dateFormat'])) {
568✔
416
            $this->dateFormat = array_merge($this->dateFormat, $params['dateFormat']);
168✔
417
            unset($params['dateFormat']);
168✔
418
        }
419

420
        $typedPropertyTypes = $this->getBuiltinPropertyTypesMap(array_keys($params));
568✔
421

422
        foreach ($params as $key => $value) {
568✔
423
            if (property_exists($this, $key)) {
215✔
424
                $this->{$key} = $this->castScalarValueForTypedProperty(
215✔
425
                    $value,
215✔
426
                    $typedPropertyTypes[$key] ?? [],
215✔
427
                );
215✔
428
            }
429
        }
430

431
        $queryClass = str_replace('Connection', 'Query', static::class);
567✔
432

433
        if (class_exists($queryClass)) {
567✔
434
            $this->queryClass = $queryClass;
441✔
435
        }
436

437
        if ($this->failover !== []) {
567✔
438
            // If there is a failover database, connect now to do failover.
439
            // Otherwise, Query Builder creates SQL statement with the main database config
440
            // (DBPrefix) even when the main database is down.
441
            $this->initialize();
2✔
442
        }
443
    }
444

445
    /**
446
     * Some config values (especially env overrides without clear source type)
447
     * can still reach us as strings. Coerce them for typed properties to keep
448
     * strict typing compatible.
449
     *
450
     * @param list<string> $types
451
     */
452
    private function castScalarValueForTypedProperty(mixed $value, array $types): mixed
453
    {
454
        if (! is_string($value)) {
215✔
455
            return $value;
184✔
456
        }
457

458
        if ($types === [] || in_array('string', $types, true) || in_array('mixed', $types, true)) {
215✔
459
            return $value;
215✔
460
        }
461

462
        $trimmedValue = trim($value);
5✔
463

464
        if (in_array('null', $types, true) && strtolower($trimmedValue) === 'null') {
5✔
465
            return null;
1✔
466
        }
467

468
        if (in_array('int', $types, true) && preg_match('/^[+-]?\d+$/', $trimmedValue) === 1) {
5✔
469
            return (int) $trimmedValue;
2✔
470
        }
471

472
        if (in_array('float', $types, true) && is_numeric($trimmedValue)) {
4✔
UNCOV
473
            return (float) $trimmedValue;
×
474
        }
475

476
        if (in_array('bool', $types, true) || in_array('false', $types, true) || in_array('true', $types, true)) {
4✔
477
            $boolValue = filter_var($trimmedValue, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
3✔
478

479
            if ($boolValue !== null) {
3✔
480
                if (in_array('bool', $types, true)) {
3✔
481
                    return $boolValue;
2✔
482
                }
483

484
                if ($boolValue === false && in_array('false', $types, true)) {
1✔
485
                    return false;
1✔
486
                }
487

488
                if ($boolValue === true && in_array('true', $types, true)) {
1✔
489
                    return true;
1✔
490
                }
491
            }
492
        }
493

494
        return $value;
1✔
495
    }
496

497
    /**
498
     * @param list<string> $properties
499
     *
500
     * @return array<string, list<string>>
501
     */
502
    private function getBuiltinPropertyTypesMap(array $properties): array
503
    {
504
        $className = static::class;
568✔
505
        $requested = array_fill_keys($properties, true);
568✔
506

507
        if (! isset(self::$propertyBuiltinTypesCache[$className])) {
568✔
508
            self::$propertyBuiltinTypesCache[$className] = [];
26✔
509
        }
510

511
        // Fill only the properties requested by this call that are not cached yet.
512
        $missing = array_diff_key($requested, self::$propertyBuiltinTypesCache[$className]);
568✔
513

514
        if ($missing !== []) {
568✔
515
            $reflection = new ReflectionClass($className);
37✔
516

517
            foreach ($reflection->getProperties() as $property) {
37✔
518
                $propertyName = $property->getName();
37✔
519

520
                if (! isset($missing[$propertyName])) {
37✔
521
                    continue;
37✔
522
                }
523

524
                $type = $property->getType();
37✔
525

526
                if (! $type instanceof ReflectionType) {
37✔
527
                    self::$propertyBuiltinTypesCache[$className][$propertyName] = [];
36✔
528

529
                    continue;
36✔
530
                }
531

532
                $namedTypes   = $type instanceof ReflectionUnionType ? $type->getTypes() : [$type];
18✔
533
                $builtinTypes = [];
18✔
534

535
                foreach ($namedTypes as $namedType) {
18✔
536
                    if (! $namedType instanceof ReflectionNamedType || ! $namedType->isBuiltin()) {
18✔
UNCOV
537
                        continue;
×
538
                    }
539

540
                    $builtinTypes[] = $namedType->getName();
18✔
541
                }
542

543
                if ($type->allowsNull() && ! in_array('null', $builtinTypes, true)) {
18✔
544
                    $builtinTypes[] = 'null';
13✔
545
                }
546

547
                self::$propertyBuiltinTypesCache[$className][$propertyName] = $builtinTypes;
18✔
548
            }
549

550
            // Untyped or unresolved properties are cached as empty to avoid re-reflecting them.
551
            foreach (array_keys($missing) as $propertyName) {
37✔
552
                self::$propertyBuiltinTypesCache[$className][$propertyName] ??= [];
37✔
553
            }
554
        }
555

556
        $typedProperties = [];
568✔
557

558
        foreach ($properties as $property) {
568✔
559
            $typedProperties[$property] = self::$propertyBuiltinTypesCache[$className][$property] ?? [];
215✔
560
        }
561

562
        return $typedProperties;
568✔
563
    }
564

565
    /**
566
     * Initializes the database connection/settings.
567
     *
568
     * @return void
569
     *
570
     * @throws DatabaseException
571
     */
572
    public function initialize()
573
    {
574
        /* If an established connection is available, then there's
575
         * no need to connect and select the database.
576
         *
577
         * Depending on the database driver, connID can be either
578
         * boolean TRUE, a resource or an object.
579
         */
580
        if ($this->connID) {
916✔
581
            return;
834✔
582
        }
583

584
        $this->connectTime = microtime(true);
98✔
585
        $connectionErrors  = [];
98✔
586

587
        try {
588
            // Connect to the database and set the connection ID
589
            $this->connID = $this->connect($this->pConnect);
98✔
590
        } catch (Throwable $e) {
2✔
591
            $this->connID       = false;
2✔
592
            $connectionErrors[] = sprintf(
2✔
593
                'Main connection [%s]: %s',
2✔
594
                $this->DBDriver,
2✔
595
                $e->getMessage(),
2✔
596
            );
2✔
597
            log_message('error', 'Error connecting to the database: ' . $e);
2✔
598
        }
599

600
        // No connection resource? Check if there is a failover else throw an error
601
        if (! $this->connID) {
98✔
602
            // Check if there is a failover set
603
            if (! empty($this->failover) && is_array($this->failover)) {
4✔
604
                // Go over all the failovers
605
                foreach ($this->failover as $index => $failover) {
2✔
606
                    $typedPropertyTypes = $this->getBuiltinPropertyTypesMap(array_keys($failover));
2✔
607

608
                    // Replace the current settings with those of the failover
609
                    foreach ($failover as $key => $val) {
2✔
610
                        if (property_exists($this, $key)) {
2✔
611
                            $this->{$key} = $this->castScalarValueForTypedProperty(
2✔
612
                                $val,
2✔
613
                                $typedPropertyTypes[$key] ?? [],
2✔
614
                            );
2✔
615
                        }
616
                    }
617

618
                    try {
619
                        // Try to connect
620
                        $this->connID = $this->connect($this->pConnect);
2✔
621
                    } catch (Throwable $e) {
1✔
622
                        $connectionErrors[] = sprintf(
1✔
623
                            'Failover #%d [%s]: %s',
1✔
624
                            ++$index,
1✔
625
                            $this->DBDriver,
1✔
626
                            $e->getMessage(),
1✔
627
                        );
1✔
628
                        log_message('error', 'Error connecting to the database: ' . $e);
1✔
629
                    }
630

631
                    // If a connection is made break the foreach loop
632
                    if ($this->connID) {
2✔
633
                        break;
2✔
634
                    }
635
                }
636
            }
637

638
            // We still don't have a connection?
639
            if (! $this->connID) {
4✔
640
                throw new DatabaseException(sprintf(
2✔
641
                    'Unable to connect to the database.%s%s',
2✔
642
                    PHP_EOL,
2✔
643
                    implode(PHP_EOL, $connectionErrors),
2✔
644
                ));
2✔
645
            }
646
        }
647

648
        $this->connectDuration = microtime(true) - $this->connectTime;
96✔
649
    }
650

651
    /**
652
     * Close the database connection.
653
     *
654
     * @return void
655
     */
656
    public function close()
657
    {
658
        if ($this->connID) {
3✔
659
            $this->_close();
3✔
660
            $this->connID = false;
3✔
661
        }
662
    }
663

664
    /**
665
     * Keep or establish the connection if no queries have been sent for
666
     * a length of time exceeding the server's idle timeout.
667
     *
668
     * @return void
669
     */
670
    public function reconnect()
671
    {
672
        if ($this->ping() === false) {
2✔
673
            $this->close();
1✔
674
            $this->initialize();
1✔
675
        }
676
    }
677

678
    /**
679
     * Platform dependent way method for closing the connection.
680
     *
681
     * @return void
682
     */
683
    abstract protected function _close();
684

685
    /**
686
     * Check if the connection is still alive.
687
     */
688
    public function ping(): bool
689
    {
690
        if ($this->connID === false) {
5✔
691
            return false;
2✔
692
        }
693

694
        return $this->_ping();
4✔
695
    }
696

697
    /**
698
     * Driver-specific ping implementation.
699
     */
700
    protected function _ping(): bool
701
    {
702
        try {
703
            $result = $this->simpleQuery('SELECT 1');
4✔
704

705
            return $result !== false;
4✔
UNCOV
706
        } catch (DatabaseException) {
×
UNCOV
707
            return false;
×
708
        }
709
    }
710

711
    /**
712
     * Create a persistent database connection.
713
     *
714
     * @return false|TConnection
715
     */
716
    public function persistentConnect()
717
    {
UNCOV
718
        return $this->connect(true);
×
719
    }
720

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

735
    /**
736
     * Returns the name of the current database being used.
737
     */
738
    public function getDatabase(): string
739
    {
740
        return empty($this->database) ? '' : $this->database;
854✔
741
    }
742

743
    /**
744
     * Set DB Prefix
745
     *
746
     * Set's the DB Prefix to something new without needing to reconnect
747
     *
748
     * @param string $prefix The prefix
749
     */
750
    public function setPrefix(string $prefix = ''): string
751
    {
752
        return $this->DBPrefix = $prefix;
14✔
753
    }
754

755
    /**
756
     * Returns the database prefix.
757
     */
758
    public function getPrefix(): string
759
    {
760
        return $this->DBPrefix;
13✔
761
    }
762

763
    /**
764
     * The name of the platform in use (MySQLi, Postgre, SQLite3, OCI8, etc)
765
     */
766
    public function getPlatform(): string
767
    {
768
        return $this->DBDriver;
23✔
769
    }
770

771
    /**
772
     * Sets the Table Aliases to use. These are typically
773
     * collected during use of the Builder, and set here
774
     * so queries are built correctly.
775
     *
776
     * @return $this
777
     */
778
    public function setAliasedTables(array $aliases)
779
    {
780
        $this->aliasedTables = $aliases;
1,109✔
781

782
        return $this;
1,109✔
783
    }
784

785
    /**
786
     * Add a table alias to our list.
787
     *
788
     * @return $this
789
     */
790
    public function addTableAlias(string $alias)
791
    {
792
        if ($alias === '') {
36✔
793
            return $this;
6✔
794
        }
795

796
        if (! in_array($alias, $this->aliasedTables, true)) {
30✔
797
            $this->aliasedTables[] = $alias;
30✔
798
        }
799

800
        return $this;
30✔
801
    }
802

803
    /**
804
     * Executes the query against the database.
805
     *
806
     * @return false|TResult
807
     */
808
    abstract protected function execute(string $sql);
809

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

828
        if (empty($this->connID)) {
904✔
829
            $this->initialize();
60✔
830
        }
831

832
        /** @var Query $query */
833
        $query = new $queryClass($this);
904✔
834

835
        $query->setQuery($sql, $binds, $setEscapeFlags);
904✔
836

837
        if (! empty($this->swapPre) && ! empty($this->DBPrefix)) {
904✔
UNCOV
838
            $query->swapPrefix($this->DBPrefix, $this->swapPre);
×
839
        }
840

841
        $startTime = microtime(true);
904✔
842

843
        // Always save the last query so we can use
844
        // the getLastQuery() method.
845
        $this->lastQuery = $query;
904✔
846

847
        // If $pretend is true, then we just want to return
848
        // the actual query object here. There won't be
849
        // any results to return.
850
        if ($this->pretend) {
904✔
851
            $query->setDuration($startTime);
11✔
852

853
            return $query;
11✔
854
        }
855

856
        // Run the query for real
857
        try {
858
            $exception           = null;
904✔
859
            $this->lastException = null;
904✔
860
            $this->resultID      = $this->simpleQuery($query->getQuery());
904✔
861
        } catch (DatabaseException $exception) {
20✔
862
            $this->resultID = false;
20✔
863
        }
864

865
        if ($this->resultID === false) {
904✔
866
            $query->setDuration($startTime, $startTime);
43✔
867

868
            // This will trigger a rollback if transactions are being used
869
            $this->handleTransStatus($exception ?? $this->lastException);
43✔
870

871
            if (
872
                $this->DBDebug
43✔
873
                && (
874
                    // Not in transactions
875
                    $this->transDepth === 0
43✔
876
                    // In transactions, do not throw exception by default.
43✔
877
                    || $this->transException
43✔
878
                )
879
            ) {
880
                // We call this function in order to roll-back queries
881
                // if transactions are enabled. If we don't call this here
882
                // the error message will trigger an exit, causing the
883
                // transactions to remain in limbo.
884
                while ($this->transDepth !== 0) {
16✔
885
                    $transDepth = $this->transDepth;
4✔
886
                    $this->transComplete();
4✔
887

888
                    if ($transDepth === $this->transDepth) {
4✔
UNCOV
889
                        log_message('error', 'Database: Failure during an automated transaction commit/rollback!');
×
UNCOV
890
                        break;
×
891
                    }
892
                }
893

894
                // Let others do something with this query.
895
                Events::trigger('DBQuery', $query);
16✔
896

897
                if ($exception instanceof DatabaseException) {
16✔
898
                    throw $exception;
14✔
899
                }
900

901
                return false;
2✔
902
            }
903

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

907
            return false;
27✔
908
        }
909

910
        $query->setDuration($startTime);
903✔
911

912
        // Let others do something with this query
913
        Events::trigger('DBQuery', $query);
903✔
914

915
        // resultID is not false, so it must be successful
916
        if ($this->isWriteType($sql)) {
903✔
917
            return true;
864✔
918
        }
919

920
        // query is not write-type, so it must be read-type query; return QueryResult
921
        $resultClass = str_replace('Connection', 'Result', static::class);
901✔
922

923
        return new $resultClass($this->connID, $this->resultID);
901✔
924
    }
925

926
    /**
927
     * Performs a basic query against the database. No binding or caching
928
     * is performed, nor are transactions handled. Simply takes a raw
929
     * query string and returns the database-specific result id.
930
     *
931
     * @return false|TResult
932
     */
933
    public function simpleQuery(string $sql)
934
    {
935
        if (empty($this->connID)) {
911✔
936
            $this->initialize();
6✔
937
        }
938

939
        return $this->execute($sql);
911✔
940
    }
941

942
    /**
943
     * Disable Transactions
944
     *
945
     * This permits transactions to be disabled at run-time.
946
     *
947
     * @return void
948
     */
949
    public function transOff()
950
    {
951
        $this->transEnabled = false;
2✔
952
    }
953

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

972
        return $this;
6✔
973
    }
974

975
    /**
976
     * Start Transaction
977
     */
978
    public function transStart(bool $testMode = false): bool
979
    {
980
        if (! $this->transEnabled) {
64✔
UNCOV
981
            return false;
×
982
        }
983

984
        return $this->transBegin($testMode);
64✔
985
    }
986

987
    /**
988
     * If set to true, exceptions are thrown during transactions.
989
     *
990
     * @return $this
991
     */
992
    public function transException(bool $transException)
993
    {
994
        $this->transException = $transException;
5✔
995

996
        return $this;
5✔
997
    }
998

999
    /**
1000
     * Complete Transaction
1001
     */
1002
    public function transComplete(): bool
1003
    {
1004
        if (! $this->transEnabled) {
76✔
UNCOV
1005
            return false;
×
1006
        }
1007

1008
        // The query() function will set this flag to FALSE in the event that a query failed
1009
        if ($this->transStatus === false || $this->transFailure === true) {
76✔
1010
            try {
1011
                $this->transRollback();
29✔
1012
            } finally {
1013
                // If we are NOT running in strict mode, we will reset
1014
                // the _trans_status flag so that subsequent groups of
1015
                // transactions will be permitted.
1016
                if ($this->transStrict === false) {
29✔
1017
                    $this->transStatus = true;
29✔
1018
                }
1019
            }
1020

1021
            return false;
27✔
1022
        }
1023

1024
        return $this->transCommit();
55✔
1025
    }
1026

1027
    /**
1028
     * Lets you retrieve the transaction flag to determine if it has failed
1029
     */
1030
    public function transStatus(): bool
1031
    {
1032
        return $this->transStatus;
19✔
1033
    }
1034

1035
    /**
1036
     * Checks whether this connection is inside an active transaction.
1037
     */
1038
    public function inTransaction(): bool
1039
    {
1040
        return $this->transDepth > 0;
6✔
1041
    }
1042

1043
    /**
1044
     * Register a callback to run after the outermost transaction commits.
1045
     *
1046
     * If no transaction is active, the callback runs immediately.
1047
     *
1048
     * @param callable(): void $callback
1049
     *
1050
     * @return $this
1051
     */
1052
    public function afterCommit(callable $callback): static
1053
    {
1054
        if ($this->transDepth === 0) {
13✔
1055
            $callback();
2✔
1056

1057
            return $this;
2✔
1058
        }
1059

1060
        $this->transCommitCallbacks[] = $callback;
11✔
1061

1062
        return $this;
11✔
1063
    }
1064

1065
    /**
1066
     * Register a callback to run after the outermost transaction rolls back.
1067
     *
1068
     * If no transaction is active, the callback is not run.
1069
     *
1070
     * @param callable(): void $callback
1071
     *
1072
     * @return $this
1073
     */
1074
    public function afterRollback(callable $callback): static
1075
    {
1076
        if ($this->transDepth === 0) {
15✔
1077
            return $this;
1✔
1078
        }
1079

1080
        $this->transRollbackCallbacks[] = $callback;
14✔
1081

1082
        return $this;
14✔
1083
    }
1084

1085
    /**
1086
     * Run the callback inside a transaction.
1087
     *
1088
     * @template TReturn
1089
     *
1090
     * @param callable(self): TReturn $callback
1091
     * @param positive-int            $attempts
1092
     *
1093
     * @return false|TReturn
1094
     */
1095
    public function transaction(callable $callback, int $attempts = 1): mixed
1096
    {
1097
        if ($attempts < 1) {
28✔
1098
            throw new InvalidArgumentException('Transaction attempts must be a positive integer.');
1✔
1099
        }
1100

1101
        if (! $this->transEnabled) {
27✔
1102
            return $callback($this);
1✔
1103
        }
1104

1105
        $attempts = $this->transDepth === 0 ? $attempts : 1;
26✔
1106

1107
        for ($attempt = 1; $attempt <= $attempts; $attempt++) {
26✔
1108
            if (! $this->transBegin()) {
26✔
1109
                return false;
1✔
1110
            }
1111

1112
            try {
1113
                $result = $callback($this);
25✔
1114
            } catch (Throwable $e) {
13✔
1115
                try {
1116
                    $this->transRollback();
13✔
1117
                } catch (Throwable $rollbackException) {
2✔
1118
                    log_message('error', 'Database: Transaction callback threw an exception before rollback failed: ' . $e);
2✔
1119

1120
                    throw $rollbackException;
2✔
1121
                } finally {
1122
                    if ($this->transDepth > 0) {
13✔
1123
                        $this->transStatus = false;
3✔
1124
                    } elseif ($this->transStrict === false) {
10✔
1125
                        $this->transStatus = true;
13✔
1126
                    }
1127
                }
1128

1129
                if ($this->transDepth === 0 && $e instanceof RetryableTransactionException && $attempt < $attempts) {
11✔
1130
                    $this->prepareTransactionRetry();
4✔
1131

1132
                    continue;
4✔
1133
                }
1134

1135
                throw $e;
8✔
1136
            }
1137

1138
            if (! $this->transComplete()) {
15✔
1139
                if ($this->transDepth === 0 && $this->transFailureException instanceof RetryableTransactionException && $attempt < $attempts) {
5✔
1140
                    $this->prepareTransactionRetry();
2✔
1141

1142
                    continue;
2✔
1143
                }
1144

1145
                return false;
3✔
1146
            }
1147

1148
            return $result;
11✔
1149
        }
1150

UNCOV
1151
        return false;
×
1152
    }
1153

1154
    /**
1155
     * Begin Transaction
1156
     */
1157
    public function transBegin(bool $testMode = false): bool
1158
    {
1159
        if (! $this->transEnabled) {
96✔
1160
            return false;
1✔
1161
        }
1162

1163
        // When transactions are nested we only begin/commit/rollback the outermost ones
1164
        if ($this->transDepth > 0) {
95✔
1165
            $this->transDepth++;
7✔
1166

1167
            return true;
7✔
1168
        }
1169

1170
        if (empty($this->connID)) {
95✔
1171
            $this->initialize();
12✔
1172
        }
1173

1174
        // Reset the transaction failure flag.
1175
        // If the $testMode flag is set to TRUE transactions will be rolled back
1176
        // even if the queries produce a successful result.
1177
        $this->transFailure          = $testMode;
95✔
1178
        $this->transFailureException = null;
95✔
1179

1180
        if ($this->_transBegin()) {
95✔
1181
            $this->transDepth++;
94✔
1182

1183
            return true;
94✔
1184
        }
1185

1186
        return false;
1✔
1187
    }
1188

1189
    /**
1190
     * Commit Transaction
1191
     */
1192
    public function transCommit(): bool
1193
    {
1194
        if (! $this->transEnabled || $this->transDepth === 0) {
59✔
UNCOV
1195
            return false;
×
1196
        }
1197

1198
        // When transactions are nested we only begin/commit/rollback the outermost ones
1199
        if ($this->transDepth > 1 || $this->_transCommit()) {
59✔
1200
            $this->transDepth--;
59✔
1201

1202
            if ($this->transDepth === 0) {
59✔
1203
                $this->transRollbackCallbacks = [];
58✔
1204
                $this->runTransCommitCallbacks();
58✔
1205
            }
1206

1207
            return true;
57✔
1208
        }
1209

1210
        return false;
1✔
1211
    }
1212

1213
    /**
1214
     * Rollback Transaction
1215
     */
1216
    public function transRollback(): bool
1217
    {
1218
        if (! $this->transEnabled || $this->transDepth === 0) {
47✔
1219
            return false;
1✔
1220
        }
1221

1222
        // When transactions are nested we only begin/commit/rollback the outermost ones
1223
        if ($this->transDepth > 1 || $this->_transRollback()) {
47✔
1224
            $this->transDepth--;
45✔
1225

1226
            if ($this->transDepth === 0) {
45✔
1227
                $this->transCommitCallbacks = [];
45✔
1228
                $this->runTransRollbackCallbacks();
45✔
1229
            }
1230

1231
            return true;
40✔
1232
        }
1233

1234
        return false;
3✔
1235
    }
1236

1237
    /**
1238
     * Reset transaction status - to restart transactions after strict mode failure
1239
     */
1240
    public function resetTransStatus(): static
1241
    {
1242
        $this->transStatus = true;
5✔
1243

1244
        return $this;
5✔
1245
    }
1246

1247
    /**
1248
     * Handle transaction status when a query fails
1249
     *
1250
     * @internal This method is for internal database component use only
1251
     */
1252
    public function handleTransStatus(?DatabaseException $exception = null): void
1253
    {
1254
        if ($this->transDepth !== 0) {
55✔
1255
            $this->transStatus = false;
26✔
1256
            $this->transFailureException ??= $exception;
26✔
1257
        }
1258
    }
1259

1260
    /**
1261
     * Reset transaction state that should not leak into a retry attempt.
1262
     */
1263
    protected function prepareTransactionRetry(): void
1264
    {
1265
        $this->transStatus           = true;
6✔
1266
        $this->transFailureException = null;
6✔
1267
        $this->lastException         = null;
6✔
1268
    }
1269

1270
    /**
1271
     * Run and clear callbacks registered for a successful transaction commit.
1272
     */
1273
    protected function runTransCommitCallbacks(): void
1274
    {
1275
        $callbacks                  = $this->transCommitCallbacks;
58✔
1276
        $this->transCommitCallbacks = [];
58✔
1277

1278
        foreach ($callbacks as $callback) {
58✔
1279
            $callback();
10✔
1280
        }
1281
    }
1282

1283
    /**
1284
     * Run and clear callbacks registered for a transaction rollback.
1285
     */
1286
    protected function runTransRollbackCallbacks(): void
1287
    {
1288
        $callbacks                    = $this->transRollbackCallbacks;
45✔
1289
        $this->transRollbackCallbacks = [];
45✔
1290

1291
        foreach ($callbacks as $callback) {
45✔
1292
            $callback();
13✔
1293
        }
1294
    }
1295

1296
    /**
1297
     * Begin Transaction
1298
     */
1299
    abstract protected function _transBegin(): bool;
1300

1301
    /**
1302
     * Commit Transaction
1303
     */
1304
    abstract protected function _transCommit(): bool;
1305

1306
    /**
1307
     * Rollback Transaction
1308
     */
1309
    abstract protected function _transRollback(): bool;
1310

1311
    /**
1312
     * Returns a non-shared new instance of the query builder for this connection.
1313
     *
1314
     * @param array|string|TableName $tableName
1315
     *
1316
     * @return BaseBuilder
1317
     *
1318
     * @throws DatabaseException
1319
     */
1320
    public function table($tableName)
1321
    {
1322
        if (empty($tableName)) {
1,056✔
UNCOV
1323
            throw new DatabaseException('You must set the database table to be used with your query.');
×
1324
        }
1325

1326
        $className = str_replace('Connection', 'Builder', static::class);
1,056✔
1327

1328
        return new $className($tableName, $this);
1,056✔
1329
    }
1330

1331
    /**
1332
     * Returns a new instance of the BaseBuilder class with a cleared FROM clause.
1333
     */
1334
    public function newQuery(): BaseBuilder
1335
    {
1336
        // save table aliases
1337
        $tempAliases         = $this->aliasedTables;
22✔
1338
        $builder             = $this->table(',')->from([], true);
22✔
1339
        $this->aliasedTables = $tempAliases;
22✔
1340

1341
        return $builder;
22✔
1342
    }
1343

1344
    /**
1345
     * Creates a prepared statement with the database that can then
1346
     * be used to execute multiple statements against. Within the
1347
     * closure, you would build the query in any normal way, though
1348
     * the Query Builder is the expected manner.
1349
     *
1350
     * Example:
1351
     *    $stmt = $db->prepare(function($db)
1352
     *           {
1353
     *             return $db->table('users')
1354
     *                   ->where('id', 1)
1355
     *                     ->get();
1356
     *           })
1357
     *
1358
     * @param Closure(BaseConnection): mixed $func
1359
     *
1360
     * @return BasePreparedQuery|null
1361
     */
1362
    public function prepare(Closure $func, array $options = [])
1363
    {
1364
        if (empty($this->connID)) {
17✔
UNCOV
1365
            $this->initialize();
×
1366
        }
1367

1368
        $this->pretend();
17✔
1369

1370
        $sql = $func($this);
17✔
1371

1372
        $this->pretend(false);
17✔
1373

1374
        if ($sql instanceof QueryInterface) {
17✔
1375
            $sql = $sql->getOriginalQuery();
17✔
1376
        }
1377

1378
        $class = str_ireplace('Connection', 'PreparedQuery', static::class);
17✔
1379
        /** @var BasePreparedQuery $class */
1380
        $class = new $class($this);
17✔
1381

1382
        return $class->prepare($sql, $options);
17✔
1383
    }
1384

1385
    /**
1386
     * Returns the last query's statement object.
1387
     *
1388
     * @return Query
1389
     */
1390
    public function getLastQuery()
1391
    {
1392
        return $this->lastQuery;
11✔
1393
    }
1394

1395
    /**
1396
     * Returns a string representation of the last query's statement object.
1397
     */
1398
    public function showLastQuery(): string
1399
    {
UNCOV
1400
        return (string) $this->lastQuery;
×
1401
    }
1402

1403
    /**
1404
     * Returns the time we started to connect to this database in
1405
     * seconds with microseconds.
1406
     *
1407
     * Used by the Debug Toolbar's timeline.
1408
     */
1409
    public function getConnectStart(): ?float
1410
    {
1411
        return $this->connectTime;
1✔
1412
    }
1413

1414
    /**
1415
     * Returns the number of seconds with microseconds that it took
1416
     * to connect to the database.
1417
     *
1418
     * Used by the Debug Toolbar's timeline.
1419
     */
1420
    public function getConnectDuration(int $decimals = 6): string
1421
    {
1422
        return number_format($this->connectDuration, $decimals);
2✔
1423
    }
1424

1425
    /**
1426
     * Protect Identifiers
1427
     *
1428
     * This function is used extensively by the Query Builder class, and by
1429
     * a couple functions in this class.
1430
     * It takes a column or table name (optionally with an alias) and inserts
1431
     * the table prefix onto it. Some logic is necessary in order to deal with
1432
     * column names that include the path. Consider a query like this:
1433
     *
1434
     * SELECT hostname.database.table.column AS c FROM hostname.database.table
1435
     *
1436
     * Or a query with aliasing:
1437
     *
1438
     * SELECT m.member_id, m.member_name FROM members AS m
1439
     *
1440
     * Since the column name can include up to four segments (host, DB, table, column)
1441
     * or also have an alias prefix, we need to do a bit of work to figure this out and
1442
     * insert the table prefix (if it exists) in the proper position, and escape only
1443
     * the correct identifiers.
1444
     *
1445
     * @param array|int|string|TableName $item
1446
     * @param bool                       $prefixSingle       Prefix a table name with no segments?
1447
     * @param bool                       $protectIdentifiers Protect table or column names?
1448
     * @param bool                       $fieldExists        Supplied $item contains a column name?
1449
     *
1450
     * @return ($item is array ? array : string)
1451
     */
1452
    public function protectIdentifiers($item, bool $prefixSingle = false, ?bool $protectIdentifiers = null, bool $fieldExists = true)
1453
    {
1454
        if (! is_bool($protectIdentifiers)) {
1,245✔
1455
            $protectIdentifiers = $this->protectIdentifiers;
1,212✔
1456
        }
1457

1458
        if (is_array($item)) {
1,245✔
1459
            $escapedArray = [];
1✔
1460

1461
            foreach ($item as $k => $v) {
1✔
1462
                $escapedArray[$this->protectIdentifiers($k)] = $this->protectIdentifiers($v, $prefixSingle, $protectIdentifiers, $fieldExists);
1✔
1463
            }
1464

1465
            return $escapedArray;
1✔
1466
        }
1467

1468
        if ($item instanceof TableName) {
1,245✔
1469
            /** @psalm-suppress NoValue I don't know why ERROR. */
1470
            return $this->escapeTableName($item);
2✔
1471
        }
1472

1473
        // If you pass `['column1', 'column2']`, `$item` will be int because the array keys are int.
1474
        $item = (string) $item;
1,245✔
1475

1476
        // This is basically a bug fix for queries that use MAX, MIN, etc.
1477
        // If a parenthesis is found we know that we do not need to
1478
        // escape the data or add a prefix. There's probably a more graceful
1479
        // way to deal with this, but I'm not thinking of it
1480
        //
1481
        // Added exception for single quotes as well, we don't want to alter
1482
        // literal strings.
1483
        if (strcspn($item, "()'") !== strlen($item)) {
1,245✔
1484
            /** @psalm-suppress NoValue I don't know why ERROR. */
1485
            return $item;
865✔
1486
        }
1487

1488
        // Do not protect identifiers and do not prefix, no swap prefix, there is nothing to do
1489
        if ($protectIdentifiers === false && $prefixSingle === false && $this->swapPre === '') {
1,234✔
1490
            /** @psalm-suppress NoValue I don't know why ERROR. */
1491
            return $item;
113✔
1492
        }
1493

1494
        // Convert tabs or multiple spaces into single spaces
1495
        /** @psalm-suppress NoValue I don't know why ERROR. */
1496
        $item = preg_replace('/\s+/', ' ', trim($item));
1,233✔
1497

1498
        // If the item has an alias declaration we remove it and set it aside.
1499
        // Note: strripos() is used in order to support spaces in table names
1500
        if ($offset = strripos($item, ' AS ')) {
1,233✔
1501
            $alias = ($protectIdentifiers) ? substr($item, $offset, 4) . $this->escapeIdentifiers(substr($item, $offset + 4)) : substr($item, $offset);
11✔
1502
            $item  = substr($item, 0, $offset);
11✔
1503
        } elseif ($offset = strrpos($item, ' ')) {
1,228✔
1504
            $alias = ($protectIdentifiers) ? ' ' . $this->escapeIdentifiers(substr($item, $offset + 1)) : substr($item, $offset);
16✔
1505
            $item  = substr($item, 0, $offset);
16✔
1506
        } else {
1507
            $alias = '';
1,221✔
1508
        }
1509

1510
        // Break the string apart if it contains periods, then insert the table prefix
1511
        // in the correct location, assuming the period doesn't indicate that we're dealing
1512
        // with an alias. While we're at it, we will escape the components
1513
        if (str_contains($item, '.')) {
1,233✔
1514
            return $this->protectDotItem($item, $alias, $protectIdentifiers, $fieldExists);
159✔
1515
        }
1516

1517
        // In some cases, especially 'from', we end up running through
1518
        // protect_identifiers twice. This algorithm won't work when
1519
        // it contains the escapeChar so strip it out.
1520
        $item = trim($item, $this->escapeChar);
1,225✔
1521

1522
        // Is there a table prefix? If not, no need to insert it
1523
        if ($this->DBPrefix !== '') {
1,225✔
1524
            // Verify table prefix and replace if necessary
1525
            if ($this->swapPre !== '' && str_starts_with($item, $this->swapPre)) {
905✔
UNCOV
1526
                $item = preg_replace('/^' . $this->swapPre . '(\S+?)/', $this->DBPrefix . '\\1', $item);
×
1527
            }
1528
            // Do we prefix an item with no segments?
1529
            elseif ($prefixSingle && ! str_starts_with($item, $this->DBPrefix)) {
905✔
1530
                $item = $this->DBPrefix . $item;
898✔
1531
            }
1532
        }
1533

1534
        if ($protectIdentifiers === true && ! in_array($item, $this->reservedIdentifiers, true)) {
1,225✔
1535
            $item = $this->escapeIdentifiers($item);
1,223✔
1536
        }
1537

1538
        return $item . $alias;
1,225✔
1539
    }
1540

1541
    private function protectDotItem(string $item, string $alias, bool $protectIdentifiers, bool $fieldExists): string
1542
    {
1543
        $parts = explode('.', $item);
159✔
1544

1545
        // Does the first segment of the exploded item match
1546
        // one of the aliases previously identified? If so,
1547
        // we have nothing more to do other than escape the item
1548
        //
1549
        // NOTE: The ! empty() condition prevents this method
1550
        // from breaking when QB isn't enabled.
1551
        if (! empty($this->aliasedTables) && in_array($parts[0], $this->aliasedTables, true)) {
159✔
1552
            if ($protectIdentifiers) {
13✔
1553
                foreach ($parts as $key => $val) {
13✔
1554
                    if (! in_array($val, $this->reservedIdentifiers, true)) {
13✔
1555
                        $parts[$key] = $this->escapeIdentifiers($val);
13✔
1556
                    }
1557
                }
1558

1559
                $item = implode('.', $parts);
13✔
1560
            }
1561

1562
            return $item . $alias;
13✔
1563
        }
1564

1565
        // Is there a table prefix defined in the config file? If not, no need to do anything
1566
        if ($this->DBPrefix !== '') {
151✔
1567
            // We now add the table prefix based on some logic.
1568
            // Do we have 4 segments (hostname.database.table.column)?
1569
            // If so, we add the table prefix to the column name in the 3rd segment.
1570
            if (isset($parts[3])) {
135✔
UNCOV
1571
                $i = 2;
×
1572
            }
1573
            // Do we have 3 segments (database.table.column)?
1574
            // If so, we add the table prefix to the column name in 2nd position
1575
            elseif (isset($parts[2])) {
135✔
UNCOV
1576
                $i = 1;
×
1577
            }
1578
            // Do we have 2 segments (table.column)?
1579
            // If so, we add the table prefix to the column name in 1st segment
1580
            else {
1581
                $i = 0;
135✔
1582
            }
1583

1584
            // This flag is set when the supplied $item does not contain a field name.
1585
            // This can happen when this function is being called from a JOIN.
1586
            if ($fieldExists === false) {
135✔
UNCOV
1587
                $i++;
×
1588
            }
1589

1590
            // Verify table prefix and replace if necessary
1591
            if ($this->swapPre !== '' && str_starts_with($parts[$i], $this->swapPre)) {
135✔
UNCOV
1592
                $parts[$i] = preg_replace('/^' . $this->swapPre . '(\S+?)/', $this->DBPrefix . '\\1', $parts[$i]);
×
1593
            }
1594
            // We only add the table prefix if it does not already exist
1595
            elseif (! str_starts_with($parts[$i], $this->DBPrefix)) {
135✔
1596
                $parts[$i] = $this->DBPrefix . $parts[$i];
135✔
1597
            }
1598

1599
            // Put the parts back together
1600
            $item = implode('.', $parts);
135✔
1601
        }
1602

1603
        if ($protectIdentifiers) {
151✔
1604
            $item = $this->escapeIdentifiers($item);
151✔
1605
        }
1606

1607
        return $item . $alias;
151✔
1608
    }
1609

1610
    /**
1611
     * Escape the SQL Identifier
1612
     *
1613
     * This function escapes single identifier.
1614
     *
1615
     * @param non-empty-string|TableName $item
1616
     */
1617
    public function escapeIdentifier($item): string
1618
    {
1619
        if ($item === '') {
793✔
UNCOV
1620
            return '';
×
1621
        }
1622

1623
        if ($item instanceof TableName) {
793✔
1624
            return $this->escapeTableName($item);
7✔
1625
        }
1626

1627
        return $this->escapeChar
793✔
1628
            . str_replace(
793✔
1629
                $this->escapeChar,
793✔
1630
                $this->escapeChar . $this->escapeChar,
793✔
1631
                $item,
793✔
1632
            )
793✔
1633
            . $this->escapeChar;
793✔
1634
    }
1635

1636
    /**
1637
     * Returns escaped table name with alias.
1638
     */
1639
    private function escapeTableName(TableName $tableName): string
1640
    {
1641
        $alias = $tableName->getAlias();
7✔
1642

1643
        return $this->escapeIdentifier($tableName->getActualTableName())
7✔
1644
            . (($alias !== '') ? ' ' . $this->escapeIdentifier($alias) : '');
7✔
1645
    }
1646

1647
    /**
1648
     * Escape the SQL Identifiers
1649
     *
1650
     * This function escapes column and table names
1651
     *
1652
     * @param array|string $item
1653
     *
1654
     * @return ($item is array ? array : string)
1655
     */
1656
    public function escapeIdentifiers($item)
1657
    {
1658
        if ($this->escapeChar === '' || empty($item) || in_array($item, $this->reservedIdentifiers, true)) {
1,251✔
1659
            return $item;
5✔
1660
        }
1661

1662
        if (is_array($item)) {
1,250✔
1663
            foreach ($item as $key => $value) {
808✔
1664
                $item[$key] = $this->escapeIdentifiers($value);
808✔
1665
            }
1666

1667
            return $item;
808✔
1668
        }
1669

1670
        // Avoid breaking functions and literal values inside queries
1671
        if (ctype_digit($item)
1,250✔
1672
            || $item[0] === "'"
1,249✔
1673
            || ($this->escapeChar !== '"' && $item[0] === '"')
1,249✔
1674
            || str_contains($item, '(')) {
1,250✔
1675
            return $item;
48✔
1676
        }
1677

1678
        if ($this->pregEscapeChar === []) {
1,249✔
1679
            if (is_array($this->escapeChar)) {
404✔
UNCOV
1680
                $this->pregEscapeChar = [
×
UNCOV
1681
                    preg_quote($this->escapeChar[0], '/'),
×
UNCOV
1682
                    preg_quote($this->escapeChar[1], '/'),
×
UNCOV
1683
                    $this->escapeChar[0],
×
UNCOV
1684
                    $this->escapeChar[1],
×
UNCOV
1685
                ];
×
1686
            } else {
1687
                $this->pregEscapeChar[0] = $this->pregEscapeChar[1] = preg_quote($this->escapeChar, '/');
404✔
1688
                $this->pregEscapeChar[2] = $this->pregEscapeChar[3] = $this->escapeChar;
404✔
1689
            }
1690
        }
1691

1692
        foreach ($this->reservedIdentifiers as $id) {
1,249✔
1693
            /** @psalm-suppress NoValue I don't know why ERROR. */
1694
            if (str_contains($item, '.' . $id)) {
1,249✔
1695
                return preg_replace(
3✔
1696
                    '/' . $this->pregEscapeChar[0] . '?([^' . $this->pregEscapeChar[1] . '\.]+)' . $this->pregEscapeChar[1] . '?\./i',
3✔
1697
                    $this->pregEscapeChar[2] . '$1' . $this->pregEscapeChar[3] . '.',
3✔
1698
                    $item,
3✔
1699
                );
3✔
1700
            }
1701
        }
1702

1703
        /** @psalm-suppress NoValue I don't know why ERROR. */
1704
        return preg_replace(
1,247✔
1705
            '/' . $this->pregEscapeChar[0] . '?([^' . $this->pregEscapeChar[1] . '\.]+)' . $this->pregEscapeChar[1] . '?(\.)?/i',
1,247✔
1706
            $this->pregEscapeChar[2] . '$1' . $this->pregEscapeChar[3] . '$2',
1,247✔
1707
            $item,
1,247✔
1708
        );
1,247✔
1709
    }
1710

1711
    /**
1712
     * Prepends a database prefix if one exists in configuration
1713
     *
1714
     * @throws DatabaseException
1715
     */
1716
    public function prefixTable(string $table = ''): string
1717
    {
1718
        if ($table === '') {
3✔
UNCOV
1719
            throw new DatabaseException('A table name is required for that operation.');
×
1720
        }
1721

1722
        return $this->DBPrefix . $table;
3✔
1723
    }
1724

1725
    /**
1726
     * Returns the total number of rows affected by this query.
1727
     */
1728
    abstract public function affectedRows(): int;
1729

1730
    /**
1731
     * "Smart" Escape String
1732
     *
1733
     * Escapes data based on type.
1734
     * Sets boolean and null types
1735
     *
1736
     * @param array|bool|float|int|object|string|null $str
1737
     *
1738
     * @return ($str is array ? array : float|int|string)
1739
     */
1740
    public function escape($str)
1741
    {
1742
        if (is_array($str)) {
1,017✔
1743
            return array_map($this->escape(...), $str);
822✔
1744
        }
1745

1746
        if ($str instanceof Stringable) {
1,017✔
1747
            if ($str instanceof RawSql) {
13✔
1748
                return $str->__toString();
12✔
1749
            }
1750

1751
            $str = (string) $str;
1✔
1752
        }
1753

1754
        if (is_string($str)) {
1,014✔
1755
            return "'" . $this->escapeString($str) . "'";
951✔
1756
        }
1757

1758
        if (is_bool($str)) {
931✔
1759
            return ($str === false) ? 0 : 1;
8✔
1760
        }
1761

1762
        return $str ?? 'NULL';
929✔
1763
    }
1764

1765
    /**
1766
     * Escape String
1767
     *
1768
     * @param list<string|Stringable>|string|Stringable $str  Input string
1769
     * @param bool                                      $like Whether the string will be used in a LIKE condition
1770
     *
1771
     * @return list<string>|string
1772
     */
1773
    public function escapeString($str, bool $like = false)
1774
    {
1775
        if (is_array($str)) {
951✔
UNCOV
1776
            foreach ($str as $key => $val) {
×
UNCOV
1777
                $str[$key] = $this->escapeString($val, $like);
×
1778
            }
1779

UNCOV
1780
            return $str;
×
1781
        }
1782

1783
        if ($str instanceof Stringable) {
951✔
1784
            if ($str instanceof RawSql) {
2✔
UNCOV
1785
                return $str->__toString();
×
1786
            }
1787

1788
            $str = (string) $str;
2✔
1789
        }
1790

1791
        $str = $this->_escapeString($str);
951✔
1792

1793
        // escape LIKE condition wildcards
1794
        if ($like) {
951✔
1795
            return str_replace(
2✔
1796
                [
2✔
1797
                    $this->likeEscapeChar,
2✔
1798
                    '%',
2✔
1799
                    '_',
2✔
1800
                ],
2✔
1801
                [
2✔
1802
                    $this->likeEscapeChar . $this->likeEscapeChar,
2✔
1803
                    $this->likeEscapeChar . '%',
2✔
1804
                    $this->likeEscapeChar . '_',
2✔
1805
                ],
2✔
1806
                $str,
2✔
1807
            );
2✔
1808
        }
1809

1810
        return $str;
951✔
1811
    }
1812

1813
    /**
1814
     * Escape LIKE String
1815
     *
1816
     * Calls the individual driver for platform
1817
     * specific escaping for LIKE conditions
1818
     *
1819
     * @param list<string|Stringable>|string|Stringable $str
1820
     *
1821
     * @return list<string>|string
1822
     */
1823
    public function escapeLikeString($str)
1824
    {
1825
        return $this->escapeString($str, true);
2✔
1826
    }
1827

1828
    /**
1829
     * Platform independent string escape.
1830
     *
1831
     * Will likely be overridden in child classes.
1832
     */
1833
    protected function _escapeString(string $str): string
1834
    {
1835
        return str_replace("'", "''", remove_invisible_characters($str, false));
905✔
1836
    }
1837

1838
    /**
1839
     * This function enables you to call PHP database functions that are not natively included
1840
     * in CodeIgniter, in a platform independent manner.
1841
     *
1842
     * @param array ...$params
1843
     *
1844
     * @throws DatabaseException
1845
     */
1846
    public function callFunction(string $functionName, ...$params): bool
1847
    {
1848
        $driver = $this->getDriverFunctionPrefix();
2✔
1849

1850
        if (! str_starts_with($functionName, $driver)) {
2✔
1851
            $functionName = $driver . $functionName;
1✔
1852
        }
1853

1854
        if (! function_exists($functionName)) {
2✔
UNCOV
1855
            if ($this->DBDebug) {
×
1856
                throw new DatabaseException('This feature is not available for the database you are using.');
×
1857
            }
1858

UNCOV
1859
            return false;
×
1860
        }
1861

1862
        return $functionName(...$params);
2✔
1863
    }
1864

1865
    /**
1866
     * Get the prefix of the function to access the DB.
1867
     */
1868
    protected function getDriverFunctionPrefix(): string
1869
    {
UNCOV
1870
        return strtolower($this->DBDriver) . '_';
×
1871
    }
1872

1873
    // --------------------------------------------------------------------
1874
    // META Methods
1875
    // --------------------------------------------------------------------
1876

1877
    /**
1878
     * Returns an array of table names
1879
     *
1880
     * @return false|list<string>
1881
     *
1882
     * @throws DatabaseException
1883
     */
1884
    public function listTables(bool $constrainByPrefix = false)
1885
    {
1886
        if (isset($this->dataCache['table_names']) && $this->dataCache['table_names']) {
856✔
1887
            $tables = $constrainByPrefix
850✔
1888
                ? preg_grep("/^{$this->DBPrefix}/", $this->dataCache['table_names'])
2✔
1889
                : $this->dataCache['table_names'];
850✔
1890

1891
            return array_values($tables);
850✔
1892
        }
1893

1894
        $sql = $this->_listTables($constrainByPrefix);
90✔
1895

1896
        if ($sql === false) {
90✔
UNCOV
1897
            if ($this->DBDebug) {
×
UNCOV
1898
                throw new DatabaseException('This feature is not available for the database you are using.');
×
1899
            }
1900

UNCOV
1901
            return false;
×
1902
        }
1903

1904
        $this->dataCache['table_names'] = [];
90✔
1905

1906
        $query = $this->query($sql);
90✔
1907

1908
        foreach ($query->getResultArray() as $row) {
90✔
1909
            /** @var string $table */
1910
            $table = $row['table_name'] ?? $row['TABLE_NAME'] ?? $row[array_key_first($row)];
87✔
1911

1912
            $this->dataCache['table_names'][] = $table;
87✔
1913
        }
1914

1915
        return $this->dataCache['table_names'];
90✔
1916
    }
1917

1918
    /**
1919
     * Determine if a particular table exists
1920
     *
1921
     * @param bool $cached Whether to use data cache
1922
     */
1923
    public function tableExists(string $tableName, bool $cached = true): bool
1924
    {
1925
        if ($cached) {
850✔
1926
            return in_array($this->protectIdentifiers($tableName, true, false, false), $this->listTables(), true);
849✔
1927
        }
1928

1929
        if (false === ($sql = $this->_listTables(false, $tableName))) {
803✔
UNCOV
1930
            if ($this->DBDebug) {
×
1931
                throw new DatabaseException('This feature is not available for the database you are using.');
×
1932
            }
1933

UNCOV
1934
            return false;
×
1935
        }
1936

1937
        $tableExists = $this->query($sql)->getResultArray() !== [];
803✔
1938

1939
        // if cache has been built already
1940
        if (! empty($this->dataCache['table_names'])) {
803✔
1941
            $key = array_search(
799✔
1942
                strtolower($tableName),
799✔
1943
                array_map(strtolower(...), $this->dataCache['table_names']),
799✔
1944
                true,
799✔
1945
            );
799✔
1946

1947
            // table doesn't exist but still in cache - lets reset cache, it can be rebuilt later
1948
            // OR if table does exist but is not found in cache
1949
            if (($key !== false && ! $tableExists) || ($key === false && $tableExists)) {
799✔
1950
                $this->resetDataCache();
1✔
1951
            }
1952
        }
1953

1954
        return $tableExists;
803✔
1955
    }
1956

1957
    /**
1958
     * Fetch Field Names
1959
     *
1960
     * @param string|TableName $tableName
1961
     *
1962
     * @return false|list<string>
1963
     *
1964
     * @throws DatabaseException
1965
     */
1966
    public function getFieldNames($tableName)
1967
    {
1968
        $table = ($tableName instanceof TableName) ? $tableName->getTableName() : $tableName;
12✔
1969

1970
        // Is there a cached result?
1971
        if (isset($this->dataCache['field_names'][$table])) {
12✔
1972
            return $this->dataCache['field_names'][$table];
7✔
1973
        }
1974

1975
        if (empty($this->connID)) {
8✔
UNCOV
1976
            $this->initialize();
×
1977
        }
1978

1979
        if (false === ($sql = $this->_listColumns($tableName))) {
8✔
UNCOV
1980
            if ($this->DBDebug) {
×
UNCOV
1981
                throw new DatabaseException('This feature is not available for the database you are using.');
×
1982
            }
1983

UNCOV
1984
            return false;
×
1985
        }
1986

1987
        $query = $this->query($sql);
8✔
1988

1989
        $this->dataCache['field_names'][$table] = [];
8✔
1990

1991
        foreach ($query->getResultArray() as $row) {
8✔
1992
            // Do we know from where to get the column's name?
1993
            if (! isset($key)) {
8✔
1994
                if (isset($row['column_name'])) {
8✔
1995
                    $key = 'column_name';
8✔
1996
                } elseif (isset($row['COLUMN_NAME'])) {
8✔
1997
                    $key = 'COLUMN_NAME';
8✔
1998
                } else {
1999
                    // We have no other choice but to just get the first element's key.
2000
                    $key = key($row);
8✔
2001
                }
2002
            }
2003

2004
            $this->dataCache['field_names'][$table][] = $row[$key];
8✔
2005
        }
2006

2007
        return $this->dataCache['field_names'][$table];
8✔
2008
    }
2009

2010
    /**
2011
     * Determine if a particular field exists
2012
     */
2013
    public function fieldExists(string $fieldName, string $tableName): bool
2014
    {
2015
        return in_array($fieldName, $this->getFieldNames($tableName), true);
8✔
2016
    }
2017

2018
    /**
2019
     * Returns an object with field data
2020
     *
2021
     * @return list<stdClass>
2022
     */
2023
    public function getFieldData(string $table)
2024
    {
2025
        return $this->_fieldData($this->protectIdentifiers($table, true, false, false));
150✔
2026
    }
2027

2028
    /**
2029
     * Returns an object with key data
2030
     *
2031
     * @return array<string, stdClass>
2032
     */
2033
    public function getIndexData(string $table)
2034
    {
2035
        return $this->_indexData($this->protectIdentifiers($table, true, false, false));
165✔
2036
    }
2037

2038
    /**
2039
     * Returns an object with foreign key data
2040
     *
2041
     * @return array<string, stdClass>
2042
     */
2043
    public function getForeignKeyData(string $table)
2044
    {
2045
        return $this->_foreignKeyData($this->protectIdentifiers($table, true, false, false));
37✔
2046
    }
2047

2048
    /**
2049
     * Converts array of arrays generated by _foreignKeyData() to array of objects
2050
     *
2051
     * @return array<string, stdClass>
2052
     *
2053
     * array[
2054
     *    {constraint_name} =>
2055
     *        stdClass[
2056
     *            'constraint_name'     => string,
2057
     *            'table_name'          => string,
2058
     *            'column_name'         => string[],
2059
     *            'foreign_table_name'  => string,
2060
     *            'foreign_column_name' => string[],
2061
     *            'on_delete'           => string,
2062
     *            'on_update'           => string,
2063
     *            'match'               => string
2064
     *        ]
2065
     * ]
2066
     */
2067
    protected function foreignKeyDataToObjects(array $data)
2068
    {
2069
        $retVal = [];
37✔
2070

2071
        foreach ($data as $row) {
37✔
2072
            $name = $row['constraint_name'];
12✔
2073

2074
            // for sqlite generate name
2075
            if ($name === null) {
12✔
2076
                $name = $row['table_name'] . '_' . implode('_', $row['column_name']) . '_foreign';
11✔
2077
            }
2078

2079
            $obj                      = new stdClass();
12✔
2080
            $obj->constraint_name     = $name;
12✔
2081
            $obj->table_name          = $row['table_name'];
12✔
2082
            $obj->column_name         = $row['column_name'];
12✔
2083
            $obj->foreign_table_name  = $row['foreign_table_name'];
12✔
2084
            $obj->foreign_column_name = $row['foreign_column_name'];
12✔
2085
            $obj->on_delete           = $row['on_delete'];
12✔
2086
            $obj->on_update           = $row['on_update'];
12✔
2087
            $obj->match               = $row['match'];
12✔
2088

2089
            $retVal[$name] = $obj;
12✔
2090
        }
2091

2092
        return $retVal;
37✔
2093
    }
2094

2095
    /**
2096
     * Disables foreign key checks temporarily.
2097
     *
2098
     * @return bool
2099
     */
2100
    public function disableForeignKeyChecks()
2101
    {
2102
        $sql = $this->_disableForeignKeyChecks();
817✔
2103

2104
        if ($sql === '') {
817✔
2105
            // The feature is not supported.
UNCOV
2106
            return false;
×
2107
        }
2108

2109
        return $this->query($sql);
817✔
2110
    }
2111

2112
    /**
2113
     * Enables foreign key checks temporarily.
2114
     *
2115
     * @return bool
2116
     */
2117
    public function enableForeignKeyChecks()
2118
    {
2119
        $sql = $this->_enableForeignKeyChecks();
900✔
2120

2121
        if ($sql === '') {
900✔
2122
            // The feature is not supported.
UNCOV
2123
            return false;
×
2124
        }
2125

2126
        return $this->query($sql);
900✔
2127
    }
2128

2129
    /**
2130
     * Allows the engine to be set into a mode where queries are not
2131
     * actually executed, but they are still generated, timed, etc.
2132
     *
2133
     * This is primarily used by the prepared query functionality.
2134
     *
2135
     * @return $this
2136
     */
2137
    public function pretend(bool $pretend = true)
2138
    {
2139
        $this->pretend = $pretend;
18✔
2140

2141
        return $this;
18✔
2142
    }
2143

2144
    /**
2145
     * Empties our data cache. Especially helpful during testing.
2146
     *
2147
     * @return $this
2148
     */
2149
    public function resetDataCache()
2150
    {
2151
        $this->dataCache = [];
36✔
2152

2153
        return $this;
36✔
2154
    }
2155

2156
    /**
2157
     * Determines if the statement is a write-type query or not.
2158
     *
2159
     * @param string $sql
2160
     */
2161
    public function isWriteType($sql): bool
2162
    {
2163
        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);
928✔
2164
    }
2165

2166
    /**
2167
     * Returns the last error code and message.
2168
     *
2169
     * Must return an array with keys 'code' and 'message':
2170
     *
2171
     * @return array{code: int|string|null, message: string|null}
2172
     */
2173
    abstract public function error(): array;
2174

2175
    /**
2176
     * Returns the exception that would have been thrown on the last failed
2177
     * query if DBDebug were enabled. Returns null if the last query succeeded
2178
     * or if DBDebug is true (in which case the exception is always thrown
2179
     * directly and this method will always return null).
2180
     */
2181
    public function getLastException(): ?DatabaseException
2182
    {
2183
        return $this->lastException;
7✔
2184
    }
2185

2186
    /**
2187
     * Sets the exception for the last failed database operation.
2188
     *
2189
     * @internal This method is for internal database component use only.
2190
     */
2191
    public function setLastException(?DatabaseException $exception): void
2192
    {
2193
        $this->lastException = $exception;
19✔
2194
    }
2195

2196
    /**
2197
     * Checks whether the native database error represents a unique constraint violation.
2198
     */
2199
    protected function isUniqueConstraintViolation(int|string $code, string $message): bool
2200
    {
2201
        return false;
7✔
2202
    }
2203

2204
    /**
2205
     * Checks whether the native database code represents a retryable transaction failure.
2206
     */
2207
    protected function isRetryableTransactionErrorCode(int|string $code): bool
2208
    {
2209
        return false;
2✔
2210
    }
2211

2212
    /**
2213
     * Creates the appropriate database exception for a native database error.
2214
     *
2215
     * @internal This method is for internal database component use only.
2216
     */
2217
    public function createDatabaseException(
2218
        string $message,
2219
        int|string $code = 0,
2220
        ?Throwable $previous = null,
2221
    ): DatabaseException {
2222
        if ($this->isUniqueConstraintViolation($code, $message)) {
81✔
2223
            return new UniqueConstraintViolationException($message, $code, $previous);
38✔
2224
        }
2225

2226
        if ($this->isRetryableTransactionErrorCode($code)) {
63✔
2227
            return new RetryableTransactionException($message, $code, $previous);
16✔
2228
        }
2229

2230
        return new DatabaseException($message, $code, $previous);
47✔
2231
    }
2232

2233
    /**
2234
     * Insert ID
2235
     *
2236
     * @return int|string
2237
     */
2238
    abstract public function insertID();
2239

2240
    /**
2241
     * Generates the SQL for listing tables in a platform-dependent manner.
2242
     *
2243
     * @param string|null $tableName If $tableName is provided will return only this table if exists.
2244
     *
2245
     * @return false|string
2246
     */
2247
    abstract protected function _listTables(bool $constrainByPrefix = false, ?string $tableName = null);
2248

2249
    /**
2250
     * Generates a platform-specific query string so that the column names can be fetched.
2251
     *
2252
     * @param string|TableName $table
2253
     *
2254
     * @return false|string
2255
     */
2256
    abstract protected function _listColumns($table = '');
2257

2258
    /**
2259
     * Platform-specific field data information.
2260
     *
2261
     * @see getFieldData()
2262
     *
2263
     * @return list<stdClass>
2264
     */
2265
    abstract protected function _fieldData(string $table): array;
2266

2267
    /**
2268
     * Platform-specific index data.
2269
     *
2270
     * @see    getIndexData()
2271
     *
2272
     * @return array<string, stdClass>
2273
     */
2274
    abstract protected function _indexData(string $table): array;
2275

2276
    /**
2277
     * Platform-specific foreign keys data.
2278
     *
2279
     * @see    getForeignKeyData()
2280
     *
2281
     * @return array<string, stdClass>
2282
     */
2283
    abstract protected function _foreignKeyData(string $table): array;
2284

2285
    /**
2286
     * Platform-specific SQL statement to disable foreign key checks.
2287
     *
2288
     * If this feature is not supported, return empty string.
2289
     *
2290
     * @TODO This method should be moved to an interface that represents foreign key support.
2291
     *
2292
     * @return string
2293
     *
2294
     * @see disableForeignKeyChecks()
2295
     */
2296
    protected function _disableForeignKeyChecks()
2297
    {
UNCOV
2298
        return '';
×
2299
    }
2300

2301
    /**
2302
     * Platform-specific SQL statement to enable foreign key checks.
2303
     *
2304
     * If this feature is not supported, return empty string.
2305
     *
2306
     * @TODO This method should be moved to an interface that represents foreign key support.
2307
     *
2308
     * @return string
2309
     *
2310
     * @see enableForeignKeyChecks()
2311
     */
2312
    protected function _enableForeignKeyChecks()
2313
    {
UNCOV
2314
        return '';
×
2315
    }
2316

2317
    /**
2318
     * Converts a named timezone to an offset string.
2319
     *
2320
     * Converts timezone identifiers (e.g., 'America/New_York') to offset strings
2321
     * (e.g., '-05:00' or '-04:00' depending on DST). This is useful because not all
2322
     * databases have timezone tables loaded, but all support offset notation.
2323
     *
2324
     * @param string $timezone Named timezone (e.g., 'America/New_York', 'UTC', 'Europe/Paris')
2325
     *
2326
     * @return string Offset string (e.g., '+00:00', '-05:00', '+01:00')
2327
     */
2328
    protected function convertTimezoneToOffset(string $timezone): string
2329
    {
2330
        // If it's already an offset, return as-is
2331
        if (preg_match('/^[+-]\d{2}:\d{2}$/', $timezone)) {
9✔
2332
            return $timezone;
3✔
2333
        }
2334

2335
        try {
2336
            $offset = Time::now($timezone)->getOffset();
6✔
2337

2338
            // Convert offset seconds to +-HH:MM format
2339
            $hours   = (int) ($offset / 3600);
5✔
2340
            $minutes = abs((int) (($offset % 3600) / 60));
5✔
2341

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

2347
            return '+00:00';
1✔
2348
        }
2349
    }
2350

2351
    /**
2352
     * Gets the timezone string to use for database session.
2353
     *
2354
     * Handles the timezone configuration logic:
2355
     * - false: Don't set timezone (returns null)
2356
     * - true: Auto-sync with app timezone from config
2357
     * - string: Use specific timezone (converts named timezones to offsets)
2358
     *
2359
     * @return string|null The timezone offset string, or null if timezone should not be set
2360
     */
2361
    protected function getSessionTimezone(): ?string
2362
    {
2363
        if ($this->timezone === false) {
74✔
2364
            return null;
68✔
2365
        }
2366

2367
        // Auto-sync with app timezone
2368
        if ($this->timezone === true) {
6✔
2369
            $appConfig = config('App');
2✔
2370
            $timezone  = $appConfig->appTimezone;
2✔
2371
        } else {
2372
            // Use specific timezone from config
2373
            $timezone = $this->timezone;
4✔
2374
        }
2375

2376
        return $this->convertTimezoneToOffset($timezone);
6✔
2377
    }
2378

2379
    /**
2380
     * Accessor for properties if they exist.
2381
     *
2382
     * @return array|bool|float|int|object|resource|string|null
2383
     */
2384
    public function __get(string $key)
2385
    {
2386
        if (property_exists($this, $key)) {
1,206✔
2387
            return $this->{$key};
1,205✔
2388
        }
2389

2390
        return null;
1✔
2391
    }
2392

2393
    /**
2394
     * Checker for properties existence.
2395
     */
2396
    public function __isset(string $key): bool
2397
    {
2398
        return property_exists($this, $key);
300✔
2399
    }
2400
}
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