Issues (45)

src/IO/Database/MySQL.php (1 issue)

1
<?php
2
/**
3
 * This file is part of the Divergence package.
4
 *
5
 * (c) Henry Paradiz <[email protected]>
6
 *
7
 * For the full copyright and license information, please view the LICENSE
8
 * file that was distributed with this source code.
9
 */
10
11
namespace Divergence\IO\Database;
12
13
use Exception;
14
use PDO as PDO;
15
use Divergence\App as App;
16
17
/**
18
 * MySQL.
19
 *
20
 * @package Divergence
21
 * @author  Henry Paradiz <[email protected]>
22
 *
23
 */
24
class MySQL
25
{
26
    /**
27
     * Timezones in TZ format
28
     *
29
     * @var string $Timezone
30
     */
31
    public static $TimeZone;
32
33
    /**
34
     * Character encoding to use
35
     *
36
     * @var string $encoding
37
     */
38
    public static $encoding = 'UTF-8';
39
40
    /**
41
     * Character set to use
42
     *
43
     * @var string $charset
44
     */
45
    public static $charset = 'utf8';
46
47
    /**
48
     * Default config label to use in production
49
     *
50
     * @var string $defaultProductionLabel
51
     */
52
    public static $defaultProductionLabel = 'mysql';
53
54
    /**
55
     * Default config label to use in development
56
     *
57
     * @var string $defaultDevLabel
58
     */
59
    public static $defaultDevLabel = 'dev-mysql';
60
61
    /**
62
     * Current connection label
63
     *
64
     * @var string|null $currentConnection
65
     */
66
    public static $currentConnection = null;
67
68
    /**
69
     * Internal reference list of connections
70
     *
71
     * @var array $Connections
72
     */
73
    protected static $Connections = [];
74
75
    /**
76
     * In-memory record cache
77
     *
78
     * @var array $_record_cache
79
     */
80
    protected static $_record_cache = [];
81
82
    /**
83
     * An internal reference to the last PDO statement returned from a query.
84
     *
85
     * @var \PDOStatement|false|null $LastStatement
86
     */
87
    protected static $LastStatement;
88
89
    /**
90
     * In-memory cache of the data in the global database config
91
     *
92
     * @var array $Config
93
     */
94
    protected static $Config;
95
96
97
    /**
98
     * Sets the connection that should be returned by getConnection when $label is null
99
     *
100
     * @param string $label
101
     * @return void
102
     */
103 1
    public static function setConnection(string $label=null)
104
    {
105 1
        if ($label === null && static::$currentConnection === null) {
106
            static::$currentConnection = static::getDefaultLabel();
107
            return;
108
        }
109
110 1
        $config = static::config();
111 1
        if (isset($config[$label])) {
112 1
            static::$currentConnection = $label;
113
        } else {
114
            throw new Exception('The provided label does not exist in the config.');
115
        }
116
    }
117
118
    /**
119
     * Attempts to make, store, and return a PDO connection.
120
     * - By default will use the label provided by static::getDefaultLabel()
121
     * - The label corresponds to a config in /config/db.php
122
     * - Also sets timezone on the connection based on static::$Timezone
123
     * - Sets static::$Connections[$label] with the connection after connecting.
124
     * - If static::$Connections[$label] already exists it will return that.
125
     *
126
     * @param string|null $label A specific connection.
127
     * @return PDO A PDO connection
128
     *
129
     * @throws Exception
130
     *
131
     * @uses static::$Connections
132
     * @uses static::getDefaultLabel()
133
     * @uses static::$Timezone
134
     * @uses PDO
135
     */
136 130
    public static function getConnection($label=null)
137
    {
138 130
        if ($label === null) {
139 130
            if (static::$currentConnection === null) {
140
                static::setConnection();
141
            }
142 130
            $label = static::$currentConnection;
143
        }
144
145 130
        if (!isset(static::$Connections[$label])) {
146 1
            static::config();
147
148 1
            $config = array_merge([
149 1
                'host' => 'localhost',
150 1
                'port' => 3306,
151 1
            ], static::$Config[$label]);
152
153 1
            if (isset($config['socket'])) {
154
                // socket connection
155 1
                $DSN = 'mysql:unix_socket=' . $config['socket'] . ';dbname=' . $config['database'];
156
            } else {
157
                // tcp connection
158 1
                $DSN = 'mysql:host=' . $config['host'] . ';port=' . $config['port'] .';dbname=' . $config['database'];
159
            }
160
161
            try {
162
                // try to initiate connection
163 1
                static::$Connections[$label] = new PDO($DSN, $config['username'], $config['password']);
164 1
            } catch (\PDOException $e) {
165 1
                throw $e;
166
                //throw new Exception('PDO failed to connect on config "'.$label.'" '.$DSN);
167
            }
168
169
            // set timezone
170
            if (!empty(static::$TimeZone)) {
171
                $q = static::$Connections[$label]->prepare('SET time_zone=?');
172
                $q->execute([static::$TimeZone]);
173
            }
174
        }
175
176 130
        return static::$Connections[$label];
177
    }
178
179
    /**
180
     * Recursive escape for strings or arrays of strings.
181
     *
182
     * @param mixed $data If string will do a simple escape. If array will iterate over array members recursively and escape any found strings.
183
     * @return mixed Same as $data input but with all found strings escaped in place.
184
     */
185 87
    public static function escape($data)
186
    {
187 87
        if (is_string($data)) {
188 69
            $data = static::getConnection()->quote($data);
189 69
            $data = substr($data, 1, strlen($data)-2);
190 69
            return $data;
191 56
        } elseif (is_array($data)) {
192 1
            foreach ($data as $key=>$string) {
193 1
                if (is_string($string)) {
194 1
                    $data[$key] = static::escape($string);
195
                }
196
            }
197 1
            return $data;
198
        }
199 56
        return $data;
200
    }
201
202
    /**
203
     * Returns affected rows from the last query.
204
     *
205
     * @return int Affected row count.
206
     */
207 9
    public static function affectedRows()
208
    {
209 9
        return static::$LastStatement->rowCount();
210
    }
211
212
    /**
213
     * Runs SELECT FOUND_ROWS() and returns the result.
214
     * @see https://dev.mysql.com/doc/refman/8.0/en/information-functions.html#function_found-rows
215
     *
216
     * @return string|int|false An integer as a string.
217
     */
218 10
    public static function foundRows()
219
    {
220 10
        return static::oneValue('SELECT FOUND_ROWS()');
221
    }
222
223
    /**
224
     * Returns the insert id from the last insert.
225
     * @see http://php.net/manual/en/pdo.lastinsertid.php
226
     * @return string An integer as a string usually.
227
     */
228 14
    public static function insertID()
229
    {
230 14
        return static::getConnection()->lastInsertId();
231
    }
232
233
    /**
234
     * Formats a query with vsprintf if you pass an array and sprintf if you pass a string.
235
     *
236
     *  This is a public pass through for the private method preprocessQuery.
237
     *
238
     * @param string $query A database query.
239
     * @param array|string $parameters Parameter(s) for vsprintf (array) or sprintf (string)
240
     * @return string A formatted query.
241
     *
242
     * @uses static::preprocessQuery
243
     */
244 1
    public static function prepareQuery($query, $parameters = [])
245
    {
246 1
        return static::preprocessQuery($query, $parameters);
247
    }
248
249
    /**
250
     * Run a query that returns no data (like update or insert)
251
     *
252
     * This method will still set static::$LastStatement
253
     *
254
     * @param string $query A MySQL query
255
     * @param array|string $parameters Optional parameters for vsprintf (array) or sprintf (string) to use for formatting the query.
256
     * @param callable $errorHandler A callback that will run in the event of an error instead of static::handleError
257
     * @return void
258
     */
259 27
    public static function nonQuery($query, $parameters = [], $errorHandler = null)
260
    {
261 27
        $query = static::preprocessQuery($query, $parameters);
262
263
        // start query log
264 27
        $queryLog = static::startQueryLog($query);
265
266
        // execute query
267
        try {
268 27
            static::$LastStatement = static::getConnection()->query($query);
269 6
        } catch (\Exception $e) {
270 6
            $ErrorInfo = $e->errorInfo;
271 6
            if ($ErrorInfo[0] != '00000') {
272 6
                static::handleException($e, $query, $queryLog, $errorHandler);
273
            }
274
        }
275
276
        // finish query log
277 25
        static::finishQueryLog($queryLog);
278
    }
279
280
    /**
281
     * Run a query and returns a PDO statement
282
     *
283
     * @param string $query A MySQL query
284
     * @param array|string $parameters Optional parameters for vsprintf (array) or sprintf (string) to use for formatting the query.
285
     * @param callable $errorHandler A callback that will run in the event of an error instead of static::handleError
286
     * @throws Exception
287
     * @return \PDOStatement
288
     */
289 111
    public static function query($query, $parameters = [], $errorHandler = null)
290
    {
291 111
        $query = static::preprocessQuery($query, $parameters);
292
293
        // start query log
294 111
        $queryLog = static::startQueryLog($query);
295
296
        // execute query
297
        try {
298 111
            static::$LastStatement = $Statement = static::getConnection()->query($query);
299
            // finish query log
300 103
            static::finishQueryLog($queryLog);
301
302 103
            return $Statement;
303 9
        } catch (\Exception $e) {
304 9
            $ErrorInfo = $e->errorInfo;
305 9
            if ($ErrorInfo[0] != '00000') {
306
                // handledException should return a PDOStatement from a successful query so let's pass this up
307 9
                $handledException = static::handleException($e, $query, $queryLog, $errorHandler);
308 2
                if (is_a($handledException, \PDOStatement::class)) {
309 1
                    static::$LastStatement = $handledException;
310
                    // start query log
311 1
                    static::startQueryLog($query);
312
313 1
                    return $handledException;
314
                } else {
315 1
                    throw $e;
316
                }
317
            }
318
        }
319
    }
320
321
    /*
322
     *  Uses $tableKey instead of primaryKey (usually ID) as the PHP array index
323
     *      Only do this with unique indexed fields. This is a helper method for that exact situation.
324
     */
325
    /**
326
     * Runs a query and returns all results as an associative array with $tableKey as the index instead of auto assignment in order of appearance by PHP.
327
     *
328
     * @param string $tableKey A column to use as an index for the returned array.
329
     * @param string $query A MySQL query
330
     * @param array|string $parameters Optional parameters for vsprintf (array) or sprintf (string) to use for formatting the query.
331
     * @param string $nullKey Optional fallback column to use as an index if the $tableKey param isn't found in a returned record.
332
     * @param callable $errorHandler A callback that will run in the event of an error instead of static::handleError
333
     * @return array Result from query or an empty array if nothing found.
334
     */
335 2
    public static function table($tableKey, $query, $parameters = [], $nullKey = '', $errorHandler = null)
336
    {
337
        // execute query
338 2
        $result = static::query($query, $parameters, $errorHandler);
339
340 2
        $records = [];
341 2
        while ($record = $result->fetch(PDO::FETCH_ASSOC)) {
342 2
            $records[$record[$tableKey] ? $record[$tableKey] : $nullKey] = $record;
343
        }
344
345 2
        return $records;
346
    }
347
348
    /**
349
     * Runs a query and returns all results as an associative array.
350
     *
351
     * @param string $query A MySQL query
352
     * @param array|string $parameters Optional parameters for vsprintf (array) or sprintf (string) to use for formatting the query.
353
     * @param callable $errorHandler A callback that will run in the event of an error instead of static::handleError
354
     * @return array Result from query or an empty array if nothing found.
355
     */
356 34
    public static function allRecords($query, $parameters = [], $errorHandler = null)
357
    {
358
        // execute query
359 34
        $result = static::query($query, $parameters, $errorHandler);
360
361 32
        $records = [];
362 32
        while ($record = $result->fetch(PDO::FETCH_ASSOC)) {
363 32
            $records[] = $record;
364
        }
365
366 32
        return $records;
367
    }
368
369
370
    /**
371
     * Gets you some column from every record.
372
     *
373
     * @param string $valueKey The name of the column you want.
374
     * @param string $query A MySQL query
375
     * @param array|string $parameters Optional parameters for vsprintf (array) or sprintf (string) to use for formatting the query.
376
     * @param callable $errorHandler A callback that will run in the event of an error instead of static::handleError
377
     * @return array The column provided in $valueKey from each found record combined as an array. Will be an empty array if no records are found.
378
     */
379 1
    public static function allValues($valueKey, $query, $parameters = [], $errorHandler = null)
380
    {
381
        // execute query
382 1
        $result = static::query($query, $parameters, $errorHandler);
383
384 1
        $records = [];
385 1
        while ($record = $result->fetch(PDO::FETCH_ASSOC)) {
386 1
            $records[] = $record[$valueKey];
387
        }
388
389 1
        return $records;
390
    }
391
392
    /**
393
     * Unsets static::$_record_cache[$cacheKey]
394
     *
395
     * @param string $cacheKey
396
     * @return void
397
     *
398
     * @uses static::$_record_cache
399
     */
400 21
    public static function clearCachedRecord($cacheKey)
401
    {
402 21
        unset(static::$_record_cache[$cacheKey]);
403
    }
404
405
    /**
406
     * Returns the first database record from a query with caching
407
     *
408
     * It is recommended that you LIMIT 1 any records you want out of this to avoid having the database doing any work.
409
     *
410
     * @param string $cacheKey A key for the cache to use for this query. If the key is found in the existing cache will return that instead of running the query.
411
     * @param string $query A MySQL query
412
     * @param array|string $parameters Optional parameters for vsprintf (array) or sprintf (string) to use for formatting the query.
413
     * @param callable $errorHandler A callback that will run in the event of an error instead of static::handleError
414
     * @return array Result from query or an empty array if nothing found.
415
     *
416
     * @uses static::$_record_cache
417
     */
418 3
    public static function oneRecordCached($cacheKey, $query, $parameters = [], $errorHandler = null)
419
    {
420
421
        // check for cached record
422 3
        if (array_key_exists($cacheKey, static::$_record_cache)) {
423
            // return cache hit
424 1
            return static::$_record_cache[$cacheKey];
425
        }
426
427
        // preprocess and execute query
428 3
        $result = static::query($query, $parameters, $errorHandler);
429
430
        // get record
431 2
        $record = $result->fetch(PDO::FETCH_ASSOC);
432
433
        // save record to cache
434 2
        static::$_record_cache[$cacheKey] = $record;
435
436
        // return record
437 2
        return $record;
438
    }
439
440
441
    /**
442
     * Returns the first database record from a query.
443
     *
444
     * It is recommended that you LIMIT 1 any records you want out of this to avoid having the database doing any work.
445
     *
446
     * @param string $query A MySQL query
447
     * @param array|string $parameters Optional parameters for vsprintf (array) or sprintf (string) to use for formatting the query.
448
     * @param callable $errorHandler A callback that will run in the event of an error instead of static::handleError
449
     * @return array Result from query or an empty array if nothing found.
450
     */
451 95
    public static function oneRecord($query, $parameters = [], $errorHandler = null)
452
    {
453
        // preprocess and execute query
454 95
        $result = static::query($query, $parameters, $errorHandler);
455
456
        // get record
457 95
        $record = $result->fetch(PDO::FETCH_ASSOC);
458
459
        // return record
460 95
        return $record;
461
    }
462
463
    /**
464
     * Returns the first value of the first database record from a query.
465
     *
466
     * @param string $query A MySQL query
467
     * @param array|string $parameters Optional parameters for vsprintf (array) or sprintf (string) to use for formatting the query.
468
     * @param callable $errorHandler A callback that will run in the event of an error instead of static::handleError
469
     * @return string|false First field from the first record from a query or false if nothing found.
470
     */
471 30
    public static function oneValue($query, $parameters = [], $errorHandler = null)
472
    {
473
        // get the first record
474 30
        $record = static::oneRecord($query, $parameters, $errorHandler);
475
476 30
        if (!empty($record)) {
477
            // return first value of the record
478 30
            return array_shift($record);
479
        } else {
480 1
            return false;
481
        }
482
    }
483
484
    /**
485
     * Handles any errors that are thrown by PDO
486
     *
487
     * If App::$App->Config['environment'] is 'dev' this method will attempt to hook into whoops and provide it with information about this query.
488
     *
489
     * @throws \RuntimeException Database error!
490
     *
491
     * @param Exception $e
492
     * @param string $query The query which caused the error.
493
     * @param boolean|array $queryLog An array created by startQueryLog containing logging information about this query.
494
     * @param callable $errorHandler An array handler to use instead of this one. If you pass this in it will run first and return directly.
495
     * @return void|mixed If $errorHandler is set to a callable it will try to run it and return anything that it returns. Otherwise void
496
     */
497 15
    public static function handleException(Exception $e, $query = '', $queryLog = false, $errorHandler = null)
498
    {
499 15
        if (is_callable($errorHandler, false, $callable)) {
500 9
            return call_user_func($errorHandler, $e, $query, $queryLog);
501
        }
502
503
        // save queryLog
504 11
        if ($queryLog) {
505 1
            $error = static::getConnection()->errorInfo();
506 1
            $queryLog['error'] = $error[2];
507 1
            static::finishQueryLog($queryLog);
508
        }
509
510
        // get error message
511 11
        $error = static::getConnection()->errorInfo();
512 11
        $message = $error[2];
513
514 11
        if (App::$App->Config['environment']=='dev') {
515
            /** @var \Whoops\Handler\PrettyPageHandler */
516 1
            $Handler = \Divergence\App::$App->whoops->popHandler();
517
518 1
            if ($Handler::class === \Whoops\Handler\PrettyPageHandler::class) {
519 1
                $Handler->addDataTable("Query Information", [
0 ignored issues
show
The method addDataTable() does not exist on Whoops\Handler\HandlerInterface. It seems like you code against a sub-type of Whoops\Handler\HandlerInterface such as Whoops\Handler\PrettyPageHandler. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

519
                $Handler->/** @scrutinizer ignore-call */ 
520
                          addDataTable("Query Information", [
Loading history...
520 1
                    'Query'     	=>	$query,
521 1
                    'Error'		=>	$message,
522 1
                    'ErrorCode'	=>	static::getConnection()->errorCode(),
523 1
                ]);
524 1
                \Divergence\App::$App->whoops->pushHandler($Handler);
525
            }
526
        }
527 11
        throw new \RuntimeException(sprintf("Database error: [%s]", static::getConnection()->errorCode()).$message);
528
    }
529
530
    /**
531
     * Formats a query with vsprintf if you pass an array and sprintf if you pass a string.
532
     *
533
     * @param string $query A database query.
534
     * @param array|string $parameters Parameter(s) for vsprintf (array) or sprintf (string)
535
     * @return string A formatted query.
536
     */
537 118
    protected static function preprocessQuery($query, $parameters = [])
538
    {
539 118
        if (is_array($parameters) && count($parameters)) {
540 6
            return vsprintf($query, $parameters);
541
        } else {
542 114
            if (isset($parameters)) {
543 52
                return sprintf($query, $parameters);
544
            } else {
545 96
                return $query;
546
            }
547
        }
548
    }
549
550
    /**
551
     * Creates an associative array containing the query and time_start
552
     *
553
     * @param string $query The query you want to start logging.
554
     * @return false|array If App::$App->Config['environment']!='dev' this will return false. Otherwise an array containing 'query' and 'time_start' members.
555
     */
556 118
    protected static function startQueryLog($query)
557
    {
558 118
        if (App::$App->Config['environment']!='dev') {
559 115
            return false;
560
        }
561
562 4
        return [
563 4
            'query' => $query,
564 4
            'time_start' => sprintf('%f', microtime(true)),
565 4
        ];
566
    }
567
568
    /**
569
     * Uses the log array created by startQueryLog and sets 'time_finish' on it as well as 'time_duration_ms'
570
     *
571
     * If a PDO result is passed it will also set 'result_fields' and 'result_rows' on the passed in array.
572
     *
573
     * Probably gonna remove this entirely. Query logging should be done via services like New Relic.
574
     *
575
     * @param array|false $queryLog Passed by reference. The query log array created by startQueryLog
576
     * @param object|false $result The result from
577
     * @return void|false
578
     */
579 108
    protected static function finishQueryLog(&$queryLog, $result = false)
580
    {
581 108
        if ($queryLog == false) {
582 106
            return false;
583
        }
584
585
        // save finish time and number of affected rows
586 3
        $queryLog['time_finish'] = sprintf('%f', microtime(true));
587 3
        $queryLog['time_duration_ms'] = ($queryLog['time_finish'] - $queryLog['time_start']) * 1000;
588
589
        // save result information
590 3
        if ($result) {
591 1
            $queryLog['result_fields'] = $result->field_count;
592 1
            $queryLog['result_rows'] = $result->num_rows;
593
        }
594
595
        // build backtrace string
596
        // TODO: figure out a nice toString option that isn't too bulky
597
        //$queryLog['backtrace'] = debug_backtrace();
598
599
        // monolog here
600
    }
601
602
    /**
603
     * Gets the database config and sets it to static::$Config
604
     *
605
     * @uses static::$Config
606
     * @uses App::config
607
     *
608
     * @return array static::$Config
609
     */
610 3
    protected static function config()
611
    {
612 3
        if (empty(static::$Config)) {
613 1
            static::$Config = App::$App->config('db');
614
        }
615
616 3
        return static::$Config;
617
    }
618
619
    /**
620
     * Gets the label we should use in the current run time based on App::$App->Config['environment']
621
     *
622
     * @uses App::$App->Config
623
     * @uses static::$defaultProductionLabel
624
     * @uses static::$defaultDevLabel
625
     *
626
     * @return string The SQL config to use in the config based on the current environment.
627
     */
628 1
    protected static function getDefaultLabel()
629
    {
630 1
        if (App::$App->Config['environment'] == 'production') {
631 1
            return static::$defaultProductionLabel;
632 1
        } elseif (App::$App->Config['environment'] == 'dev') {
633 1
            return static::$defaultDevLabel;
634
        }
635
    }
636
}
637