Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
230 / 230
100.00% covered (success)
100.00%
41 / 41
CRAP
100.00% covered (success)
100.00%
1 / 1
Database
100.00% covered (success)
100.00%
230 / 230
100.00% covered (success)
100.00%
41 / 41
85
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 __destruct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 log
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 makeConfig
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
1 / 1
1
 connect
100.00% covered (success)
100.00%
53 / 53
100.00% covered (success)
100.00%
1 / 1
12
 setCollations
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 setTimezone
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getConnection
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isOpen
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 close
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 ping
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 reconnect
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getConfig
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 warnings
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 errors
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 error
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 use
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 createSchema
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 dropSchema
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 alterSchema
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 createTable
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 dropTable
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 alterTable
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 delete
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 insert
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 loadData
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 replace
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 select
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 update
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 with
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 lastQuery
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 exec
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 query
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 prepare
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 transaction
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 insertId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 protectIdentifier
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 quote
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
7
 setDebugCollector
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 addToDebug
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 finalizeAddToDebug
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
1<?php declare(strict_types=1);
2/*
3 * This file is part of Aplus Framework Database Library.
4 *
5 * (c) Natan Felles <natanfelles@gmail.com>
6 *
7 * For the full copyright and license information, please view the LICENSE
8 * file that was distributed with this source code.
9 */
10namespace Framework\Database;
11
12use Closure;
13use Exception;
14use Framework\Database\Debug\DatabaseCollector;
15use Framework\Database\Definition\AlterSchema;
16use Framework\Database\Definition\AlterTable;
17use Framework\Database\Definition\CreateSchema;
18use Framework\Database\Definition\CreateTable;
19use Framework\Database\Definition\DropSchema;
20use Framework\Database\Definition\DropTable;
21use Framework\Database\Manipulation\Delete;
22use Framework\Database\Manipulation\Insert;
23use Framework\Database\Manipulation\LoadData;
24use Framework\Database\Manipulation\Replace;
25use Framework\Database\Manipulation\Select;
26use Framework\Database\Manipulation\Update;
27use Framework\Database\Manipulation\With;
28use Framework\Log\Logger;
29use Framework\Log\LogLevel;
30use InvalidArgumentException;
31use JetBrains\PhpStorm\ArrayShape;
32use JetBrains\PhpStorm\Language;
33use LogicException;
34use mysqli;
35use mysqli_sql_exception;
36use RuntimeException;
37use SensitiveParameter;
38
39/**
40 * Class Database.
41 *
42 * @package database
43 */
44class Database
45{
46    protected ?mysqli $mysqli;
47    /**
48     * Connection configurations.
49     *
50     * Custom configs merged with the Base Connection configurations.
51     *
52     * @see Database::makeConfig()
53     *
54     * @var array<string,mixed>
55     */
56    protected array $config = [];
57    /**
58     * The current $config failover index to be used in a connection.
59     *
60     * @see Database::connect()
61     *
62     * @var int|null Integer representing the array index or null for none
63     */
64    protected ?int $failoverIndex = null;
65    /**
66     * @see Database::transaction()
67     */
68    protected bool $inTransaction = false;
69    protected string $lastQuery = '';
70    protected ?Logger $logger;
71    protected DatabaseCollector $debugCollector;
72
73    /**
74     * Database constructor.
75     *
76     * @param array<string,mixed>|string $username
77     * @param string|null $password
78     * @param string|null $schema
79     * @param string $host
80     * @param int $port
81     * @param Logger|null $logger
82     *
83     * @see Database::makeConfig()
84     *
85     * @throws mysqli_sql_exception if connections fail
86     */
87    public function __construct(
88        #[SensitiveParameter] array | string $username,
89        #[SensitiveParameter] string $password = null,
90        string $schema = null,
91        string $host = 'localhost',
92        int $port = 3306,
93        Logger $logger = null
94    ) {
95        $this->logger = $logger;
96        $this->connect($username, $password, $schema, $host, $port);
97    }
98
99    public function __destruct()
100    {
101        $this->close();
102    }
103
104    protected function log(string $message, LogLevel $level = LogLevel::ERROR) : void
105    {
106        $this->logger?->log($level, $message);
107    }
108
109    /**
110     * Make Base Connection configurations.
111     *
112     * @param array<string,mixed> $config
113     *
114     * @return array<string,mixed>
115     */
116    #[ArrayShape([
117        'host' => 'string',
118        'port' => 'int',
119        'username' => 'string|null',
120        'password' => 'string|null',
121        'schema' => 'string|null',
122        'socket' => 'string|null',
123        'persistent' => 'bool',
124        'engine' => 'string',
125        'charset' => 'string',
126        'collation' => 'string',
127        'timezone' => 'string',
128        'ssl' => 'array',
129        'failover' => 'array',
130        'options' => 'array',
131        'report' => 'int',
132    ])]
133    protected function makeConfig(array $config) : array
134    {
135        return \array_replace_recursive([
136            'host' => 'localhost',
137            'port' => 3306,
138            'username' => null,
139            'password' => null,
140            'schema' => null,
141            'socket' => null,
142            'persistent' => false,
143            'engine' => 'InnoDB',
144            'charset' => 'utf8mb4',
145            'collation' => 'utf8mb4_general_ci',
146            'timezone' => '+00:00',
147            'ssl' => [
148                'enabled' => false,
149                'verify' => true,
150                'key' => null,
151                'cert' => null,
152                'ca' => null,
153                'capath' => null,
154                'cipher' => null,
155            ],
156            'failover' => [],
157            'options' => [
158                \MYSQLI_OPT_CONNECT_TIMEOUT => 10,
159                \MYSQLI_OPT_INT_AND_FLOAT_NATIVE => true,
160                \MYSQLI_OPT_LOCAL_INFILE => 1,
161            ],
162            'report' => \MYSQLI_REPORT_ALL & ~\MYSQLI_REPORT_INDEX,
163        ], $config);
164    }
165
166    /**
167     * @param array<string,mixed>|string $username
168     * @param string|null $password
169     * @param string|null $schema
170     * @param string $host
171     * @param int $port
172     *
173     * @throws mysqli_sql_exception if connection fail
174     *
175     * @return static
176     */
177    protected function connect(
178        #[SensitiveParameter] array | string $username,
179        #[SensitiveParameter] string $password = null,
180        string $schema = null,
181        string $host = 'localhost',
182        int $port = 3306
183    ) : static {
184        if ( ! \is_array($username)) {
185            $username = [
186                'host' => $host,
187                'port' => $port,
188                'username' => $username,
189                'password' => $password,
190                'schema' => $schema,
191            ];
192        }
193        $config = $this->makeConfig($username);
194        if ($this->failoverIndex === null) {
195            $this->config = $config;
196        }
197        \mysqli_report($config['report']);
198        $this->mysqli = new mysqli();
199        foreach ($config['options'] as $option => $value) {
200            $this->mysqli->options($option, $value);
201        }
202        try {
203            $flags = 0;
204            if ($config['ssl']['enabled'] === true) {
205                $this->mysqli->ssl_set(
206                    $config['ssl']['key'],
207                    $config['ssl']['cert'],
208                    $config['ssl']['ca'],
209                    $config['ssl']['capath'],
210                    $config['ssl']['cipher']
211                );
212                $flags += \MYSQLI_CLIENT_SSL;
213                if ($config['ssl']['verify'] === false) {
214                    $flags += \MYSQLI_CLIENT_SSL_DONT_VERIFY_SERVER_CERT;
215                }
216            }
217            $this->mysqli->real_connect(
218                ($config['persistent'] ? 'p:' : '') . $config['host'],
219                $config['username'],
220                $config['password'],
221                $config['schema'],
222                $config['port'] === null ? null : (int) $config['port'],
223                $config['socket'],
224                $flags
225            );
226        } catch (mysqli_sql_exception $exception) {
227            $log = "Database: Connection failed for '{$config['username']}'@'{$config['host']}'";
228            $log .= $this->failoverIndex !== null ? " (failover: {$this->failoverIndex})" : '';
229            $this->log($log);
230            $this->failoverIndex = $this->failoverIndex === null
231                ? 0
232                : $this->failoverIndex + 1;
233            if (empty($config['failover'][$this->failoverIndex])) {
234                throw $exception;
235            }
236            $config = \array_replace_recursive(
237                $config,
238                $config['failover'][$this->failoverIndex]
239            );
240            return $this->connect($config);
241        }
242        $this->setCollations($config['charset'], $config['collation']);
243        $this->setTimezone($config['timezone']);
244        return $this;
245    }
246
247    protected function setCollations(string $charset, string $collation) : bool
248    {
249        $this->mysqli->set_charset($charset);
250        $charset = $this->quote($charset);
251        $collation = $this->quote($collation);
252        return $this->mysqli->real_query("SET NAMES {$charset} COLLATE {$collation}");
253    }
254
255    protected function setTimezone(string $timezone) : bool
256    {
257        $timezone = $this->quote($timezone);
258        return $this->mysqli->real_query("SET time_zone = {$timezone}");
259    }
260
261    /**
262     * Gets the MySQLi connection.
263     *
264     * @return mysqli
265     */
266    public function getConnection() : mysqli
267    {
268        return $this->mysqli;
269    }
270
271    /**
272     * Tells if the connection is open.
273     *
274     * @return bool
275     */
276    public function isOpen() : bool
277    {
278        return isset($this->mysqli);
279    }
280
281    /**
282     * Closes the connection if it is open.
283     *
284     * @return bool
285     */
286    public function close() : bool
287    {
288        if ( ! $this->isOpen()) {
289            return true;
290        }
291        $closed = $this->mysqli->close();
292        if ($closed) {
293            $this->mysqli = null;
294        }
295        return $closed;
296    }
297
298    /**
299     * Pings the server, or tries to reconnect if the connection has gone down.
300     *
301     * @return bool
302     */
303    public function ping() : bool
304    {
305        return $this->mysqli->ping();
306    }
307
308    /**
309     * Closes the current and opens a new connection with the last config.
310     *
311     * @return static
312     */
313    public function reconnect() : static
314    {
315        $this->close();
316        return $this->connect($this->getConfig());
317    }
318
319    /**
320     * @return array<string,mixed>
321     */
322    #[ArrayShape([
323        'host' => 'string',
324        'port' => 'int',
325        'username' => 'string|null',
326        'password' => 'string|null',
327        'schema' => 'string|null',
328        'socket' => 'string|null',
329        'persistent' => 'bool',
330        'engine' => 'string',
331        'charset' => 'string',
332        'collation' => 'string',
333        'timezone' => 'string',
334        'ssl' => 'array',
335        'failover' => 'array',
336        'options' => 'array',
337        'report' => 'int',
338    ])]
339    public function getConfig() : array
340    {
341        return $this->config;
342    }
343
344    public function warnings() : int
345    {
346        return $this->mysqli->warning_count;
347    }
348
349    /**
350     * Get a list of the latest errors.
351     *
352     * @return array<int,array<string,mixed>>
353     */
354    public function errors() : array
355    {
356        return $this->mysqli->error_list;
357    }
358
359    /**
360     * Get latest error.
361     *
362     * @return string|null
363     */
364    public function error() : ?string
365    {
366        return $this->mysqli->error ?: null;
367    }
368
369    /**
370     * @param string $schema
371     *
372     * @throws mysqli_sql_exception if schema is unknown
373     *
374     * @return static
375     */
376    public function use(string $schema) : static
377    {
378        $this->mysqli->select_db($schema);
379        return $this;
380    }
381
382    /**
383     * Call a CREATE SCHEMA statement.
384     *
385     * @param string|null $schemaName
386     *
387     * @return CreateSchema
388     */
389    public function createSchema(string $schemaName = null) : CreateSchema
390    {
391        $instance = new CreateSchema($this);
392        if ($schemaName !== null) {
393            $instance->schema($schemaName);
394        }
395        return $instance;
396    }
397
398    /**
399     * Call a DROP SCHEMA statement.
400     *
401     * @param string|null $schemaName
402     *
403     * @return DropSchema
404     */
405    public function dropSchema(string $schemaName = null) : DropSchema
406    {
407        $instance = new DropSchema($this);
408        if ($schemaName !== null) {
409            $instance->schema($schemaName);
410        }
411        return $instance;
412    }
413
414    /**
415     * Call a ALTER SCHEMA statement.
416     *
417     * @param string|null $schemaName
418     *
419     * @return AlterSchema
420     */
421    public function alterSchema(string $schemaName = null) : AlterSchema
422    {
423        $instance = new AlterSchema($this);
424        if ($schemaName !== null) {
425            $instance->schema($schemaName);
426        }
427        return $instance;
428    }
429
430    /**
431     * Call a CREATE TABLE statement.
432     *
433     * @param string|null $tableName
434     *
435     * @return CreateTable
436     */
437    public function createTable(string $tableName = null) : CreateTable
438    {
439        $instance = new CreateTable($this);
440        if ($tableName !== null) {
441            $instance->table($tableName);
442        }
443        return $instance;
444    }
445
446    /**
447     * Call a DROP TABLE statement.
448     *
449     * @param string|null $table
450     * @param string ...$tables
451     *
452     * @return DropTable
453     */
454    public function dropTable(string $table = null, string ...$tables) : DropTable
455    {
456        $instance = new DropTable($this);
457        if ($table !== null) {
458            $instance->table($table, ...$tables);
459        }
460        return $instance;
461    }
462
463    /**
464     * Call a ALTER TABLE statement.
465     *
466     * @param string|null $tableName
467     *
468     * @return AlterTable
469     */
470    public function alterTable(string $tableName = null) : AlterTable
471    {
472        $instance = new AlterTable($this);
473        if ($tableName !== null) {
474            $instance->table($tableName);
475        }
476        return $instance;
477    }
478
479    /**
480     * Call a DELETE statement.
481     *
482     * @param array<string,Closure|string>|Closure|string|null $reference
483     * @param array<string,Closure|string>|Closure|string ...$references
484     *
485     * @return Delete
486     */
487    public function delete(
488        array | Closure | string $reference = null,
489        array | Closure | string ...$references
490    ) : Delete {
491        $instance = new Delete($this);
492        if ($reference !== null) {
493            $instance->table($reference, ...$references);
494        }
495        return $instance;
496    }
497
498    /**
499     * Call a INSERT statement.
500     *
501     * @param string|null $intoTable
502     *
503     * @return Insert
504     */
505    public function insert(string $intoTable = null) : Insert
506    {
507        $instance = new Insert($this);
508        if ($intoTable !== null) {
509            $instance->into($intoTable);
510        }
511        return $instance;
512    }
513
514    /**
515     * Call a LOAD DATA statement.
516     *
517     * @param string|null $intoTable
518     *
519     * @return LoadData
520     */
521    public function loadData(string $intoTable = null) : LoadData
522    {
523        $instance = new LoadData($this);
524        if ($intoTable !== null) {
525            $instance->intoTable($intoTable);
526        }
527        return $instance;
528    }
529
530    /**
531     * Call a REPLACE statement.
532     *
533     * @param string|null $intoTable
534     *
535     * @return Replace
536     */
537    public function replace(string $intoTable = null) : Replace
538    {
539        $instance = new Replace($this);
540        if ($intoTable !== null) {
541            $instance->into($intoTable);
542        }
543        return $instance;
544    }
545
546    /**
547     * Call a SELECT statement.
548     *
549     * @param array<string,Closure|string>|Closure|string|null $reference
550     * @param array<string,Closure|string>|Closure|string ...$references
551     *
552     * @return Select
553     */
554    public function select(
555        array | Closure | string $reference = null,
556        array | Closure | string ...$references
557    ) : Select {
558        $instance = new Select($this);
559        if ($reference !== null) {
560            $instance->from($reference, ...$references);
561        }
562        return $instance;
563    }
564
565    /**
566     * Call a UPDATE statement.
567     *
568     * @param array<string,Closure|string>|Closure|string|null $reference
569     * @param array<string,Closure|string>|Closure|string ...$references
570     *
571     * @return Update
572     */
573    public function update(
574        array | Closure | string $reference = null,
575        array | Closure | string ...$references
576    ) : Update {
577        $instance = new Update($this);
578        if ($reference !== null) {
579            $instance->table($reference, ...$references);
580        }
581        return $instance;
582    }
583
584    /**
585     * Call a WITH statement.
586     *
587     * @return With
588     */
589    public function with() : With
590    {
591        return new With($this);
592    }
593
594    public function lastQuery() : string
595    {
596        return $this->lastQuery;
597    }
598
599    /**
600     * Executes an SQL statement and return the number of affected rows.
601     *
602     * @param string $statement
603     *
604     * @return int|string
605     */
606    public function exec(#[Language('SQL')] string $statement) : int | string
607    {
608        $this->lastQuery = $statement;
609        isset($this->debugCollector)
610            ? $this->addToDebug(fn () => $this->mysqli->real_query($statement))
611            : $this->mysqli->real_query($statement);
612        if ($this->mysqli->field_count) {
613            $result = $this->mysqli->store_result();
614            if ($result) {
615                $result->free();
616            }
617        }
618        return $this->mysqli->affected_rows;
619    }
620
621    /**
622     * Executes an SQL statement, returning a result set as a Result object.
623     *
624     * Must be: SELECT, SHOW, DESCRIBE or EXPLAIN
625     *
626     * @param string $statement
627     * @param bool $buffered
628     *
629     * @see https://www.php.net/manual/en/mysqlinfo.concepts.buffering.php
630     *
631     * @throws InvalidArgumentException if $statement does not return result
632     *
633     * @return Result
634     */
635    public function query(
636        #[Language('SQL')] string $statement,
637        bool $buffered = true
638    ) : Result {
639        $this->lastQuery = $statement;
640        $resultMode = $buffered ? \MYSQLI_STORE_RESULT : \MYSQLI_USE_RESULT;
641        $result = isset($this->debugCollector)
642            ? $this->addToDebug(fn () => $this->mysqli->query($statement, $resultMode))
643            : $this->mysqli->query($statement, $resultMode);
644        if (\is_bool($result)) {
645            throw new InvalidArgumentException(
646                "Statement does not return result: {$statement}"
647            );
648        }
649        return new Result($result, $buffered);
650    }
651
652    /**
653     * Prepares a statement for execution and returns a PreparedStatement object.
654     *
655     * @param string $statement
656     *
657     * @throws RuntimeException if prepared statement fail
658     *
659     * @return PreparedStatement
660     */
661    public function prepare(#[Language('SQL')] string $statement) : PreparedStatement
662    {
663        $prepared = $this->mysqli->prepare($statement);
664        if ($prepared === false) {
665            throw new RuntimeException('Prepared statement failed: ' . $statement);
666        }
667        return new PreparedStatement($prepared);
668    }
669
670    /**
671     * Run statements in a transaction.
672     *
673     * @param callable $statements
674     *
675     * @throws Exception if statements fail
676     * @throws LogicException if transaction already is active
677     *
678     * @return static
679     */
680    public function transaction(callable $statements) : static
681    {
682        if ($this->inTransaction) {
683            throw new LogicException('Transaction already is active');
684        }
685        $this->inTransaction = true;
686        $this->mysqli->autocommit(false);
687        $this->mysqli->begin_transaction();
688        try {
689            $statements($this);
690            $this->mysqli->commit();
691        } catch (Exception $exception) {
692            $this->mysqli->rollback();
693            throw $exception;
694        } finally {
695            $this->inTransaction = false;
696        }
697        return $this;
698    }
699
700    /**
701     * Gets the LAST_INSERT_ID().
702     *
703     * Note: When an insert has many rows, this function returns the id of the
704     * first row inserted!
705     * That is default on MySQL.
706     *
707     * @return int|string
708     */
709    public function insertId() : int | string
710    {
711        return $this->mysqli->insert_id;
712    }
713
714    /**
715     * Protect identifier.
716     *
717     * @param string $identifier
718     *
719     * @see https://mariadb.com/kb/en/identifier-names/
720     *
721     * @return string
722     */
723    public function protectIdentifier(string $identifier) : string
724    {
725        if ($identifier === '*') {
726            return '*';
727        }
728        $identifier = \strtr($identifier, ['`' => '``', '.' => '`.`']);
729        $identifier = "`{$identifier}`";
730        return \strtr($identifier, ['`*`' => '*']);
731    }
732
733    /**
734     * Quote SQL values.
735     *
736     * @param bool|float|int|string|null $value Value to be quoted
737     *
738     * @see https://mariadb.com/kb/en/quote/
739     *
740     * @throws InvalidArgumentException For invalid value type
741     *
742     * @return float|int|string If the value is null, returns a string containing
743     * the word "NULL". If is false, "FALSE". If is true, "TRUE". If is a string,
744     * returns the quoted string. The types int or float returns the same input value.
745     */
746    public function quote(float | bool | int | string | null $value) : float | int | string
747    {
748        $type = \gettype($value);
749        if ($type === 'string') {
750            // @phpstan-ignore-next-line
751            $value = $this->mysqli->real_escape_string($value);
752            return "'{$value}'";
753        }
754        if ($type === 'integer' || $type === 'double') {
755            return $value; // @phpstan-ignore-line
756        }
757        if ($type === 'boolean') {
758            return $value ? 'TRUE' : 'FALSE';
759        }
760        if ($value === null) {
761            return 'NULL';
762        }
763        // @codeCoverageIgnoreStart
764        // Should never throw - all accepted types have been verified
765        throw new InvalidArgumentException("Invalid value type: {$type}");
766        // @codeCoverageIgnoreEnd
767    }
768
769    public function setDebugCollector(DatabaseCollector $collector) : static
770    {
771        $collector->setDatabase($this);
772        $this->debugCollector = $collector;
773        return $this;
774    }
775
776    protected function addToDebug(Closure $function) : mixed
777    {
778        $start = \microtime(true);
779        try {
780            $result = $function();
781        } catch (Exception $exception) {
782            $this->finalizeAddToDebug($start, $exception->getMessage());
783            throw $exception;
784        }
785        $this->finalizeAddToDebug($start);
786        return $result;
787    }
788
789    protected function finalizeAddToDebug(
790        float $start,
791        string|null $description = null
792    ) : void {
793        $end = \microtime(true);
794        $rows = $this->mysqli->affected_rows;
795        $rows = $rows < 0 ? 'error' : $rows;
796        $this->debugCollector->addData([
797            'start' => $start,
798            'end' => $end,
799            'statement' => $this->lastQuery(),
800            'rows' => $rows,
801            'description' => $description,
802        ]);
803    }
804}