Completed
Branch dev (4bcb34)
by Darko
13:52
created

DB::unix_timestamp_column()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 1
nc 1
nop 2
dl 0
loc 3
rs 10
c 0
b 0
f 0
1
<?php
2
3
namespace Blacklight\db;
4
5
use Ramsey\Uuid\Uuid;
6
use App\Models\Settings;
7
use Blacklight\ColorCLI;
8
use Blacklight\ConsoleTools;
9
use Blacklight\libraries\Cache;
10
use Blacklight\utility\Utility;
11
use Blacklight\libraries\CacheException;
12
13
/**
14
 * Class for handling connection to MySQL database using PDO.
15
 *
16
 * The class extends PDO, thereby exposing all of PDO's functionality directly
17
 * without the need to wrap each and every method here.
18
 *
19
 * Exceptions are caught and displayed to the user.
20
 * Properties are explicitly created, so IDEs can offer autocompletion for them.
21
 * @extends \PDO
22
 */
23
class DB extends \PDO
24
{
25
    /**
26
     * @var bool
27
     */
28
    public $cli;
29
30
    /**
31
     * @var mixed
32
     */
33
    public $ct;
34
35
    /**
36
     * @var \Blacklight\ColorCLI	Instance variable for logging object. Currently only ColorCLI supported,
37
     * but expanding for full logging with agnostic API planned.
38
     */
39
    public $log;
40
41
    /**
42
     * @note Setting this static causes issues when creating multiple instances of this class with different
43
     *       MySQL servers, the next instances re-uses the server of the first instance.
44
     * @var \PDO Instance of PDO class.
45
     */
46
    public $pdo = null;
47
48
    /**
49
     * @var bool
50
     */
51
    protected $_debug;
52
53
    /**
54
     * @var \Blacklight\Logger
0 ignored issues
show
Bug introduced by
The type Blacklight\Logger was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
55
     */
56
    private $debugging;
57
58
    /**
59
     * @var string Lower-cased name of DBMS in use.
60
     */
61
    private $dbSystem;
62
63
    /**
64
     * @var string Version of the Db server.
65
     */
66
    private $dbVersion;
67
68
    /**
69
     * @var string	Stored copy of the dsn used to connect.
70
     */
71
    private $dsn;
72
73
    /**
74
     * @var array    Options passed into the constructor or defaulted.
75
     */
76
    private $opts;
77
78
    /**
79
     * @var \Blacklight\libraries\Cache
80
     */
81
    private $cacheServer;
82
83
    /**
84
     * @var bool Should we cache the results of the query method?
85
     */
86
    private $cacheEnabled = false;
87
88
    /**
89
     * @var string MySQL LOW_PRIORITY DELETE option.
90
     */
91
    private $DELETE_LOW_PRIORITY = '';
92
93
    /**
94
     * @var string MYSQL QUICK DELETE option.
95
     */
96
    private $DELETE_QUICK = '';
97
98
    /**
99
     * Constructor. Sets up all necessary properties. Instantiates a PDO object
100
     * if needed, otherwise returns the current one.
101
     *
102
     * @param array $options
103
     * @throws \Exception
104
     */
105
    public function __construct(array $options = [])
106
    {
107
        $this->cli = Utility::isCLI();
108
109
        $defaults = [
110
            'checkVersion'    => false,
111
            'createDb'        => false, // create dbname if it does not exist?
112
            'ct'            => new ConsoleTools(),
113
            'dbhost'        => env('DB_HOST', '127.0.0.1'),
114
            'dbname'        => env('DB_NAME', 'nntmux'),
115
            'dbpass'        => env('DB_PASSWORD', 'nntmux'),
116
            'dbport'        => env('DB_PORT', '3306'),
117
            'dbsock'        => env('DB_SOCKET', ''),
118
            'dbtype'        => env('DB_SYSTEM', 'mysql'),
119
            'dbuser'        => env('DB_USER', 'nntmux'),
120
            'log'            => new ColorCLI(),
121
            'persist'        => false,
122
        ];
123
        $options += $defaults;
124
125
        if (! $this->cli) {
126
            $options['log'] = null;
127
        }
128
        $this->opts = $options;
129
130
        if (! empty($this->opts['dbtype'])) {
131
            $this->dbSystem = strtolower($this->opts['dbtype']);
132
        }
133
134
        if (! ($this->pdo instanceof \PDO)) {
0 ignored issues
show
introduced by
$this->pdo is always a sub-type of PDO.
Loading history...
135
            $this->initialiseDatabase();
136
        }
137
138
        $this->cacheEnabled = \defined('NN_CACHE_TYPE') && (NN_CACHE_TYPE > 0);
0 ignored issues
show
Bug introduced by
The constant Blacklight\db\NN_CACHE_TYPE was not found. Maybe you did not declare it correctly or list all dependencies?
Loading history...
139
140
        if ($this->cacheEnabled) {
141
            try {
142
                $this->cacheServer = new Cache();
143
            } catch (CacheException $error) {
144
                $this->cacheEnabled = false;
145
                $this->echoError($error->getMessage(), '__construct', 4);
146
            }
147
        }
148
149
        $this->ct = $this->opts['ct'];
150
        $this->log = $this->opts['log'];
151
152
        if ($this->opts['checkVersion']) {
153
            $this->fetchDbVersion();
154
        }
155
    }
156
157
    public function __destruct()
158
    {
159
        $this->pdo = null;
160
    }
161
162
    public function checkDbExists($name = null)
163
    {
164
        if (empty($name)) {
165
            $name = $this->opts['dbname'];
166
        }
167
168
        $found = false;
169
        $tables = $this->getTableList();
170
        foreach ($tables as $table) {
171
            if ($table['Database'] === $name) {
172
                $found = true;
173
                break;
174
            }
175
        }
176
177
        return $found;
178
    }
179
180
    /**
181
     * Looks up info for index on table.
182
     *
183
     * @param $table string Table to look at.
184
     * @param $index string Index to check.
185
     *
186
     * @return bool|array False on failure, associative array of SHOW data.
187
     */
188
    public function checkIndex($table, $index)
189
    {
190
        $result = $this->pdo->query(
191
            sprintf(
192
                "SHOW INDEX FROM %s WHERE key_name = '%s'",
193
                trim($table),
194
                trim($index)
195
            )
196
        );
197
        if ($result === false) {
198
            return false;
199
        }
200
201
        return $result->fetch(\PDO::FETCH_ASSOC);
202
    }
203
204
    public function checkColumnIndex($table, $column)
205
    {
206
        $result = $this->pdo->query(
207
            sprintf(
208
                "SHOW INDEXES IN %s WHERE non_unique = 0 AND column_name = '%s'",
209
                trim($table),
210
                trim($column)
211
            )
212
        );
213
        if ($result === false) {
214
            return false;
215
        }
216
217
        return $result->fetchAll(\PDO::FETCH_ASSOC);
218
    }
219
220
    public function getTableList()
221
    {
222
        $result = $this->pdo->query('SHOW DATABASES');
223
224
        return $result->fetchAll(\PDO::FETCH_ASSOC);
225
    }
226
227
    /**
228
     * Attempts to determine if the Db is on the local machine.
229
     *
230
     * If the method returns true, then the Db is definitely on the local machine. However,
231
     * returning false only indicates that it could not positively be determined to be local - so
232
     * assume remote.
233
     *
234
     * @return bool Whether the Db is definitely on the local machine.
235
     */
236
    public function isLocalDb(): bool
237
    {
238
        $local = false;
239
        if (! empty($this->opts['dbsock']) || $this->opts['dbhost'] === 'localhost') {
240
            $local = true;
241
        } else {
242
            preg_match_all('/inet'.'6?'.' addr: ?([^ ]+)/', `ifconfig`, $ips);
243
244
            // Check for dotted quad - if exists compare against local IP number(s)
245
            if (preg_match('#^\d+\.\d+\.\d+\.\d+$#', $this->opts['dbhost'])) {
246
                if (\in_array($this->opts['dbhost'], $ips[1], false)) {
247
                    $local = true;
248
                }
249
            }
250
        }
251
252
        return $local;
253
    }
254
255
    /**
256
     * Init PDO instance.
257
     *
258
     * @throws \RuntimeException
259
     */
260
    private function initialiseDatabase()
261
    {
262
        if (! empty($this->opts['dbsock'])) {
263
            $dsn = $this->dbSystem.':unix_socket='.$this->opts['dbsock'];
264
        } else {
265
            $dsn = $this->dbSystem.':host='.$this->opts['dbhost'];
266
            if (! empty($this->opts['dbport'])) {
267
                $dsn .= ';port='.$this->opts['dbport'];
268
            }
269
        }
270
        $dsn .= ';charset=utf8';
271
272
        $options = [
273
            \PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION,
274
            \PDO::ATTR_TIMEOUT => 180,
275
            \PDO::ATTR_PERSISTENT => $this->opts['persist'],
276
            \PDO::MYSQL_ATTR_LOCAL_INFILE => true,
277
        ];
278
279
        $this->dsn = $dsn;
280
        // removed try/catch to let the instantiating code handle the problem (Install for
281
        // instance can output a message that connecting failed.
282
        $this->pdo = new \PDO($dsn, $this->opts['dbuser'], $this->opts['dbpass'], $options);
283
284
        if ($this->opts['dbname'] !== '') {
285
            if ($this->opts['createDb']) {
286
                $found = $this->checkDbExists();
287
                if ($found) {
288
                    try {
289
                        $this->pdo->exec('DROP DATABASE '.$this->opts['dbname']);
290
                    } catch (\Exception $e) {
291
                        throw new \RuntimeException("Error trying to drop your old database: '{$this->opts['dbname']}'", 2);
292
                    }
293
                    $found = $this->checkDbExists();
294
                }
295
296
                if ($found) {
297
                    throw new \RuntimeException("Could not drop your old database: '{$this->opts['dbname']}'", 2);
298
                }
299
                $this->pdo->exec("CREATE DATABASE `{$this->opts['dbname']}`  DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci");
300
301
                if (! $this->checkDbExists()) {
302
                    throw new \RuntimeException("Could not create new database: '{$this->opts['dbname']}'", 3);
303
                }
304
            }
305
            $this->pdo->exec("USE {$this->opts['dbname']}");
306
        }
307
308
        // In case PDO is not set to produce exceptions (PHP's default behaviour).
309
        if ($this->pdo === false) {
0 ignored issues
show
introduced by
The condition $this->pdo === false is always false.
Loading history...
310
            $this->echoError(
311
                'Unable to create connection to the Database!',
312
                'initialiseDatabase',
313
                1,
314
                true
315
            );
316
        }
317
318
        // For backwards compatibility, no need for a patch.
319
        $this->pdo->setAttribute(\PDO::ATTR_CASE, \PDO::CASE_LOWER);
320
        $this->pdo->setAttribute(\PDO::ATTR_DEFAULT_FETCH_MODE, \PDO::FETCH_ASSOC);
321
    }
322
323
    /**
324
     * Echo error, optionally exit.
325
     *
326
     * @param string     $error    The error message.
327
     * @param string     $method   The method where the error occured.
328
     * @param int        $severity The severity of the error.
329
     * @param bool       $exit     Exit or not?
330
     */
331
    protected function echoError($error, $method, $severity, $exit = false)
332
    {
333
        if ($this->_debug) {
334
            $this->debugging->log(__CLASS__, $method, $error, $severity);
335
336
            echo
337
            $this->cli ? ColorCLI::error($error).PHP_EOL : '<div class="error">'.$error.'</div>';
338
        }
339
340
        if ($exit) {
341
            exit();
0 ignored issues
show
Best Practice introduced by
Using exit here is not recommended.

In general, usage of exit should be done with care and only when running in a scripting context like a CLI script.

Loading history...
342
        }
343
    }
344
345
    /**
346
     * @return string mysql.
347
     */
348
    public function DbSystem(): string
349
    {
350
        return $this->dbSystem;
351
    }
352
353
    /**
354
     * Returns a string, escaped with single quotes, false on failure. http://www.php.net/manual/en/pdo.quote.php.
355
     *
356
     * @param string $str
357
     *
358
     * @return string
359
     */
360
    public function escapeString($str): string
361
    {
362
        if ($str === null) {
0 ignored issues
show
introduced by
The condition $str === null is always false.
Loading history...
363
            return 'NULL';
364
        }
365
366
        return $this->pdo->quote($str);
367
    }
368
369
    /**
370
     * Formats a 'like' string. ex.(LIKE '%chocolate%').
371
     *
372
     * @param string $str    The string.
373
     * @param bool   $left   Add a % to the left.
374
     * @param bool   $right  Add a % to the right.
375
     *
376
     * @return string
377
     */
378
    public function likeString($str, $left = true, $right = true): string
379
    {
380
        return 'LIKE '.$this->escapeString(($left ? '%' : '').$str.($right ? '%' : ''));
381
    }
382
383
    /**
384
     * Verify if pdo var is instance of PDO class.
385
     *
386
     * @return bool
387
     */
388
    public function isInitialised(): bool
389
    {
390
        return $this->pdo instanceof \PDO;
391
    }
392
393
    /**
394
     * For inserting a row. Returns last insert ID. queryExec is better if you do not need the id.
395
     *
396
     * @param string $query
397
     *
398
     * @return int|false|string
399
     */
400
    public function queryInsert($query)
401
    {
402
        if (! $this->parseQuery($query)) {
403
            return false;
404
        }
405
406
        $i = 2;
407
        $error = '';
408
        while ($i < 11) {
409
            $result = $this->queryExecHelper($query, true);
410
            if (\is_array($result) && isset($result['deadlock'])) {
411
                $error = $result['message'];
0 ignored issues
show
Unused Code introduced by
The assignment to $error is dead and can be removed.
Loading history...
412
                if ($result['deadlock'] === true) {
413
                    $this->echoError(
414
                        'A Deadlock or lock wait timeout has occurred, sleeping. ('.
415
                        ($i - 1).')',
416
                        'queryInsert',
417
                        4
418
                    );
419
                    $this->ct->showsleep($i * ($i / 2));
420
                    $i++;
421
                } else {
422
                    break;
423
                }
424
            } elseif ($result === false) {
425
                $error = 'Unspecified error.';
426
                break;
427
            } else {
428
                return $result;
429
            }
430
        }
431
432
        return false;
433
    }
434
435
    /**
436
     * Delete rows from MySQL.
437
     *
438
     * @param string $query
439
     * @param bool   $silent Echo or log errors?
440
     *
441
     * @return bool|\PDOStatement
442
     */
443
    public function queryDelete($query, $silent = false)
444
    {
445
        // Accommodate for chained queries (SELECT 1;DELETE x FROM y)
446
        if (preg_match('#(.*?[^a-z0-9]|^)DELETE\s+(.+?)$#is', $query, $matches)) {
447
            $query = $matches[1].'DELETE '.$this->DELETE_LOW_PRIORITY.$this->DELETE_QUICK.$matches[2];
448
        }
449
450
        return $this->queryExec($query, $silent);
451
    }
452
453
    /**
454
     * Used for deleting, updating (and inserting without needing the last insert id).
455
     *
456
     * @param string $query
457
     * @param bool   $silent Echo or log errors?
458
     *
459
     * @return bool|\PDOStatement
460
     */
461
    public function queryExec($query, $silent = false)
0 ignored issues
show
Unused Code introduced by
The parameter $silent is not used and could be removed. ( Ignorable by Annotation )

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

461
    public function queryExec($query, /** @scrutinizer ignore-unused */ $silent = false)

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
462
    {
463
        if (! $this->parseQuery($query)) {
464
            return false;
465
        }
466
467
        $i = 2;
468
        $error = '';
469
        while ($i < 11) {
470
            $result = $this->queryExecHelper($query);
471
            if (\is_array($result) && isset($result['deadlock'])) {
472
                $error = $result['message'];
0 ignored issues
show
Unused Code introduced by
The assignment to $error is dead and can be removed.
Loading history...
473
                if ($result['deadlock'] === true) {
474
                    $this->echoError('A Deadlock or lock wait timeout has occurred, sleeping. ('.($i - 1).')', 'queryExec', 4);
475
                    $this->ct->showsleep($i * ($i / 2));
476
                    $i++;
477
                } else {
478
                    break;
479
                }
480
            } elseif ($result === false) {
481
                $error = 'Unspecified error.';
482
                break;
483
            } else {
484
                return $result;
485
            }
486
        }
487
488
        return false;
489
    }
490
491
    /**
492
     * Helper method for queryInsert and queryExec, checks for deadlocks.
493
     *
494
     * @param $query
495
     * @param bool $insert
496
     * @return array|\PDOStatement|string
497
     * @throws \RuntimeException
498
     */
499
    protected function queryExecHelper($query, $insert = false)
500
    {
501
        try {
502
            if ($insert === false) {
503
                return $this->pdo->query($query);
504
            }
505
            $this->pdo->exec($query);
506
507
            return $this->pdo->lastInsertId();
508
        } catch (\PDOException $e) {
509
            // Deadlock or lock wait timeout, try 10 times.
510
            if (
511
                $e->errorInfo[1] === 1213 ||
512
                $e->errorInfo[0] === 40001 ||
513
                $e->errorInfo[1] === 1205 ||
514
                $e->getMessage() === 'SQLSTATE[40001]: Serialization failure: 1213 Deadlock found when trying to get lock; try restarting transaction'
515
            ) {
516
                return ['deadlock' => true, 'message' => $e->getMessage()];
517
            }
518
519
            // Check if we lost connection to MySQL.
520
            if ($this->_checkGoneAway($e->getMessage()) !== false) {
521
522
                // Reconnect to MySQL.
523
                if ($this->_reconnect() === true) {
524
525
                    // If we reconnected, retry the query.
526
                    return $this->queryExecHelper($query, $insert);
527
                }
528
            }
529
530
            return ['deadlock' => false, 'message' => $e->getMessage()];
531
        }
532
    }
533
534
    /**
535
     * Direct query. Return the affected row count. http://www.php.net/manual/en/pdo.exec.php.
536
     *
537
     * @note If not "consumed", causes this error:
538
     *       'SQLSTATE[HY000]: General error: 2014 Cannot execute queries while other unbuffered queries are active.
539
     *        Consider using PDOStatement::fetchAll(). Alternatively, if your code is only ever going to run against mysql,
540
     *        you may enable query buffering by setting the PDO::MYSQL_ATTR_USE_BUFFERED_QUERY attribute.'
541
     *
542
     * @param string $query
543
     * @param bool $silent Whether to skip echoing errors to the console.
544
     *
545
     * @return bool|int|\PDOStatement
546
     * @throws \RuntimeException
547
     */
548
    public function exec($query, $silent = false)
549
    {
550
        if (! $this->parseQuery($query)) {
551
            return false;
552
        }
553
554
        try {
555
            return $this->pdo->exec($query);
556
        } catch (\PDOException $e) {
557
558
            // Check if we lost connection to MySQL.
559
            if ($this->_checkGoneAway($e->getMessage()) !== false) {
560
561
                // Reconnect to MySQL.
562
                if ($this->_reconnect() === true) {
563
564
                    // If we reconnected, retry the query.
565
                    return $this->exec($query, $silent);
566
                } else {
567
                    // If we are not reconnected, return false.
568
                    return false;
569
                }
570
            } elseif (! $silent) {
571
                $this->echoError($e->getMessage(), 'Exec', 4, false);
572
            }
573
574
            return false;
575
        }
576
    }
577
578
    /**
579
     * Returns an array of result (empty array if no results or an error occurs)
580
     * Optional: Pass true to cache the result with a cache server.
581
     *
582
     * @param string $query       SQL to execute.
583
     * @param bool   $cache       Indicates if the query result should be cached.
584
     * @param int    $cacheExpiry The time in seconds before deleting the query result from the cache server.
585
     *
586
     * @return array|bool Array of results (possibly empty) on success, empty array on failure.
587
     */
588
    public function query($query, $cache = false, $cacheExpiry = 600): array
589
    {
590
        if (! $this->parseQuery($query)) {
591
            return false;
0 ignored issues
show
Bug Best Practice introduced by
The expression return false returns the type false which is incompatible with the type-hinted return array.
Loading history...
592
        }
593
594
        if ($cache === true && $this->cacheEnabled === true) {
595
            try {
596
                $data = $this->cacheServer->get($this->cacheServer->createKey($query));
597
                if ($data !== false) {
598
                    return $data;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $data could return the type string which is incompatible with the type-hinted return array. Consider adding an additional type-check to rule them out.
Loading history...
599
                }
600
            } catch (CacheException $error) {
601
                $this->echoError($error->getMessage(), 'query', 4);
602
            }
603
        }
604
605
        $result = $this->queryArray($query);
606
607
        if ($result !== false && $cache === true && $this->cacheEnabled === true) {
608
            $this->cacheServer->set($this->cacheServer->createKey($query), $result, $cacheExpiry);
609
        }
610
611
        return ($result === false) ? [] : $result;
612
    }
613
614
    /**
615
     * Returns a multidimensional array of result of the query function return and the count of found rows
616
     * Note: Query passed to this function SHOULD include SQL_CALC_FOUND_ROWS
617
     * Optional: Pass true to cache the result with a cache server.
618
     *
619
     * @param string $query       SQL to execute.
620
     * @param bool   $cache       Indicates if the query result should be cached.
621
     * @param int    $cacheExpiry The time in seconds before deleting the query result from the cache server.
622
     *
623
     * @return array Array of results (possibly empty) on success, empty array on failure.
624
     */
625
    public function queryCalc($query, $cache = false, $cacheExpiry = 600): array
626
    {
627
        $data = $this->query($query, $cache, $cacheExpiry);
628
629
        if (strpos($query, 'SQL_CALC_FOUND_ROWS') === false) {
630
            return $data;
631
        }
632
633
        // Remove LIMIT and OFFSET from query to allow queryCalc usage with browse
634
        $query = preg_replace('#(\s+LIMIT\s+\d+)?\s+OFFSET\s+\d+\s*$#i', '', $query);
635
636
        if ($cache === true && $this->cacheEnabled === true) {
637
            try {
638
                $count = $this->cacheServer->get($this->cacheServer->createKey($query.'count'));
639
                if ($count !== false) {
640
                    return ['total' => $count, 'result' => $data];
641
                }
642
            } catch (CacheException $error) {
643
                $this->echoError($error->getMessage(), 'queryCalc', 4);
644
            }
645
        }
646
647
        $result = $this->queryOneRow('SELECT FOUND_ROWS() AS total');
648
649
        if ($result !== false && $cache === true && $this->cacheEnabled === true) {
650
            $this->cacheServer->set($this->cacheServer->createKey($query.'count'), $result['total'], $cacheExpiry);
651
        }
652
653
        return
654
            [
655
                'total' => $result === false ? 0 : $result['total'],
656
                'result' => $data,
657
            ];
658
    }
659
660
    /**
661
     * Main method for creating results as an array.
662
     *
663
     * @param string $query SQL to execute.
664
     *
665
     * @return array|bool Array of results on success or false on failure.
666
     */
667
    public function queryArray($query)
668
    {
669
        $result = false;
670
        if (! empty($query)) {
671
            $result = $this->queryDirect($query);
672
673
            if (! empty($result)) {
674
                $result = $result->fetchAll();
675
            }
676
        }
677
678
        return $result;
679
    }
680
681
    /**
682
     * Returns all results as an associative array.
683
     *
684
     * Do not use this function for large dat-asets, as it can cripple the Db server and use huge
685
     * amounts of RAM. Instead iterate through the data.
686
     *
687
     * @param string $query The query to execute.
688
     *
689
     * @return array|bool Array of results on success, false otherwise.
690
     */
691
    public function queryAssoc($query)
692
    {
693
        if ($query === '') {
694
            return false;
695
        }
696
        $mode = $this->pdo->getAttribute(\PDO::ATTR_DEFAULT_FETCH_MODE);
697
        if ($mode !== \PDO::FETCH_ASSOC) {
698
            $this->pdo->setAttribute(\PDO::ATTR_DEFAULT_FETCH_MODE, \PDO::FETCH_ASSOC);
699
        }
700
701
        $result = $this->queryArray($query);
702
703
        if ($mode !== \PDO::FETCH_ASSOC) {
704
            // Restore old mode
705
            $this->pdo->setAttribute(\PDO::ATTR_DEFAULT_FETCH_MODE, $mode);
706
        }
707
708
        return $result;
709
    }
710
711
    /**
712
     * Query without returning an empty array like our function query(). http://php.net/manual/en/pdo.query.php.
713
     *
714
     * @param string $query The query to run.
715
     * @param bool $ignore Ignore errors, do not log them?
716
     *
717
     * @return bool|\PDOStatement
718
     * @throws \RuntimeException
719
     */
720
    public function queryDirect($query, $ignore = false)
721
    {
722
        if (! $this->parseQuery($query)) {
723
            return false;
724
        }
725
726
        try {
727
            $result = $this->pdo->query($query);
728
        } catch (\PDOException $e) {
729
730
            // Check if we lost connection to MySQL.
731
            if ($this->_checkGoneAway($e->getMessage()) !== false) {
732
733
                // Reconnect to MySQL.
734
                if ($this->_reconnect() === true) {
735
736
                    // If we reconnected, retry the query.
737
                    $result = $this->queryDirect($query);
738
                } else {
739
                    // If we are not reconnected, return false.
740
                    $result = false;
741
                }
742
            } else {
743
                if ($ignore === false) {
744
                    $this->echoError($e->getMessage(), 'queryDirect', 4, false);
745
                }
746
                $result = false;
747
            }
748
        }
749
750
        return $result;
751
    }
752
753
    /**
754
     * Reconnect to MySQL when the connection has been lost.
755
     *
756
     * @see ping(), _checkGoneAway() for checking the connection.
757
     *
758
     * @return bool
759
     * @throws \RuntimeException
760
     */
761
    protected function _reconnect(): bool
762
    {
763
        $this->initialiseDatabase();
764
765
        // Check if we are really connected to MySQL.
766
        return ! ($this->ping() === false);
767
    }
768
769
    /**
770
     * Verify that we've lost a connection to MySQL.
771
     *
772
     * @param string $errorMessage
773
     *
774
     * @return bool
775
     */
776
    protected function _checkGoneAway($errorMessage): bool
777
    {
778
        return stripos($errorMessage, 'MySQL server has gone away') !== false;
779
    }
780
781
    /**
782
     * Returns the first row of the query.
783
     *
784
     * @param string $query
785
     * @param bool   $appendLimit
786
     *
787
     * @return array|bool
788
     */
789
    public function queryOneRow($query, $appendLimit = true)
790
    {
791
        // Force the query to only return 1 row, so queryArray doesn't potentially run out of memory on a large data set.
792
        // First check if query already contains a LIMIT clause.
793
        if (preg_match('#\s+LIMIT\s+(?P<lower>\d+)(,\s+(?P<upper>\d+))?(;)?$#i', $query, $matches)) {
794
            if (! isset($matches['upper']) && isset($matches['lower']) && $matches['lower'] == 1) {
0 ignored issues
show
Unused Code introduced by
This if statement is empty and can be removed.

This check looks for the bodies of if statements that have no statements or where all statements have been commented out. This may be the result of changes for debugging or the code may simply be obsolete.

These if bodies can be removed. If you have an empty if but statements in the else branch, consider inverting the condition.

if (rand(1, 6) > 3) {
//print "Check failed";
} else {
    print "Check succeeded";
}

could be turned into

if (rand(1, 6) <= 3) {
    print "Check succeeded";
}

This is much more concise to read.

Loading history...
795
                // good it's already correctly set.
796
            } else {
797
                // We have a limit, but it's not for a single row
798
                return false;
799
            }
800
        } elseif ($appendLimit) {
801
            $query .= ' LIMIT 1';
802
        }
803
804
        $rows = $this->query($query);
805
        if (! $rows || \count($rows) === 0) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $rows of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
806
            $rows = false;
807
        }
808
809
        return \is_array($rows) ? $rows[0] : $rows;
810
    }
811
812
    /**
813
     * Optimises/repairs tables on mysql.
814
     *
815
     * @param bool $admin If we are on web, don't echo.
816
     * @param string $type 'full' | '' Force optimize of all tables.
817
     *                         'space'     Optimise tables with 5% or more free space.
818
     *                         'analyze'   Analyze tables to rebuild statistics.
819
     * @param bool|string $local Only analyze local tables. Good if running replication.
820
     * @param array $tableList (optional) Names of tables to analyze.
821
     *
822
     * @return int Quantity optimized/analyzed
823
     * @throws \RuntimeException
824
     */
825
    public function optimise($admin = false, $type = '', $local = false, $tableList = [])
826
    {
827
        $tableAnd = '';
828
        if (\count($tableList)) {
829
            foreach ($tableList as $tableName) {
830
                $tableAnd .= ($this->escapeString($tableName).',');
831
            }
832
            $tableAnd = (' AND Name IN ('.rtrim($tableAnd, ',').')');
833
        }
834
835
        switch ($type) {
836
            case 'space':
837
                $tableArray = $this->queryDirect('SHOW TABLE STATUS WHERE Data_free / Data_length > 0.005'.$tableAnd);
838
                $myIsamTables = $this->queryDirect("SHOW TABLE STATUS WHERE ENGINE LIKE 'myisam' AND Data_free / Data_length > 0.005".$tableAnd);
839
                break;
840
            case 'analyze':
841
            case '':
842
            case 'full':
843
            default:
844
                $tableArray = $this->queryDirect('SHOW TABLE STATUS WHERE 1=1'.$tableAnd);
845
                $myIsamTables = $this->queryDirect("SHOW TABLE STATUS WHERE ENGINE LIKE 'myisam'".$tableAnd);
846
                break;
847
        }
848
849
        $optimised = 0;
850
        if ($tableArray instanceof \Traversable && $tableArray->rowCount()) {
0 ignored issues
show
Bug introduced by
The method rowCount() does not exist on Traversable. It seems like you code against a sub-type of Traversable such as PDOStatement or Doctrine\DBAL\Driver\SQL...re\SQLAnywhereStatement or Doctrine\DBAL\Driver\IBMDB2\DB2Statement or Illuminate\Pagination\LengthAwarePaginator or Doctrine\DBAL\Driver\OCI8\OCI8Statement or Illuminate\Support\Collection or Illuminate\Http\Resources\Json\ResourceCollection or Doctrine\DBAL\Driver\Mysqli\MysqliStatement or Doctrine\DBAL\Cache\ResultCacheStatement or Predis\Client or Doctrine\Tests\Mocks\DriverStatementMock or Doctrine\DBAL\Statement or Doctrine\DBAL\Driver\SQLSrv\SQLSrvStatement or Doctrine\DBAL\Portability\Statement or Illuminate\Pagination\Paginator or Doctrine\DBAL\Cache\ResultCacheStatement or Doctrine\DBAL\Driver\Statement. ( Ignorable by Annotation )

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

850
        if ($tableArray instanceof \Traversable && $tableArray->/** @scrutinizer ignore-call */ rowCount()) {
Loading history...
851
            $tableNames = '';
852
            foreach ($tableArray as $table) {
853
                $tableNames .= $table['name'].',';
854
            }
855
            $tableNames = rtrim($tableNames, ',');
856
857
            $local = $local ? 'LOCAL' : '';
858
            if ($type === 'analyze') {
859
                $this->queryExec(sprintf('ANALYZE %s TABLE %s', $local, $tableNames));
860
                $this->logOptimize($admin, 'ANALYZE', $tableNames);
861
            } else {
862
                $this->queryExec(sprintf('OPTIMIZE %s TABLE %s', $local, $tableNames));
863
                $this->logOptimize($admin, 'OPTIMIZE', $tableNames);
864
865
                if ($myIsamTables instanceof \Traversable && $myIsamTables->rowCount()) {
866
                    $tableNames = '';
867
                    foreach ($myIsamTables as $table) {
868
                        $tableNames .= $table['name'].',';
869
                    }
870
                    $tableNames = rtrim($tableNames, ',');
871
                    $this->queryExec(sprintf('REPAIR %s TABLE %s', $local, $tableNames));
872
                    $this->logOptimize($admin, 'REPAIR', $tableNames);
873
                }
874
                $this->queryExec(sprintf('FLUSH %s TABLES', $local));
875
            }
876
            $optimised = $tableArray->rowCount();
877
        }
878
879
        return $optimised;
880
    }
881
882
    /**
883
     * Log/echo repaired/optimized/analyzed tables.
884
     *
885
     * @param bool   $web    If we are on web, don't echo.
886
     * @param string $type   ANALYZE|OPTIMIZE|REPAIR
887
     * @param string $tables Table names.
888
     *
889
     * @void
890
     */
891
    private function logOptimize($web, $type, $tables)
892
    {
893
        $message = $type.' ('.$tables.')';
894
        if ($web === false) {
895
            echo ColorCLI::primary($message);
896
        }
897
    }
898
899
    /**
900
     * Turns off autocommit until commit() is ran. http://www.php.net/manual/en/pdo.begintransaction.php.
901
     *
902
     * @return bool
903
     */
904
    public function beginTransaction(): bool
905
    {
906
        return $this->pdo->beginTransaction();
907
    }
908
909
    /**
910
     * Commits a transaction. http://www.php.net/manual/en/pdo.commit.php.
911
     *
912
     * @return bool
913
     */
914
    public function Commit(): bool
915
    {
916
        return $this->pdo->commit();
917
    }
918
919
    /**
920
     * Rollback transcations. http://www.php.net/manual/en/pdo.rollback.php.
921
     *
922
     * @return bool
923
     */
924
    public function Rollback(): bool
925
    {
926
        return $this->pdo->rollBack();
927
    }
928
929
    public function setCovers()
930
    {
931
        $path = Settings::settingValue([
932
            'section'    => 'site',
933
            'subsection' => 'main',
934
            'name'       => 'coverspath',
935
        ]);
936
        Utility::setCoversConstant($path);
937
    }
938
939
    public function rowToArray(array $row)
940
    {
941
        $this->settings[$row['setting']] = $row['value'];
0 ignored issues
show
Bug Best Practice introduced by
The property settings does not exist. Although not strictly required by PHP, it is generally a best practice to declare properties explicitly.
Loading history...
942
    }
943
944
    public function rowsToArray(array $rows)
945
    {
946
        foreach ($rows as $row) {
947
            if (\is_array($row)) {
948
                $this->rowToArray($row);
949
            }
950
        }
951
952
        return $this->settings;
953
    }
954
955
    public function settingsUpdate($form)
956
    {
957
        $error = $this->settingsValidate($form);
958
959
        if ($error === null) {
960
            $sql = $sqlKeys = [];
961
            foreach ($form as $settingK => $settingV) {
962
                $sql[] = sprintf(
963
                    'WHEN %s THEN %s',
964
                    $this->escapeString(trim($settingK)),
965
                    $this->escapeString(trim($settingV))
966
                );
967
                $sqlKeys[] = $this->escapeString(trim($settingK));
968
            }
969
970
            $this->queryExec(
971
                sprintf(
972
                    'UPDATE settings SET value = CASE setting %s END WHERE setting IN (%s)',
973
                    implode(' ', $sql),
974
                    implode(', ', $sqlKeys)
975
                )
976
            );
977
        } else {
978
            $form = $error;
979
        }
980
981
        return $form;
982
    }
983
984
    protected function settingsValidate(array $fields)
985
    {
986
        $defaults = [
987
            'checkpasswordedrar' => false,
988
            'ffmpegpath'         => '',
989
            'mediainfopath'      => '',
990
            'nzbpath'            => '',
991
            'tmpunrarpath'       => '',
992
            'unrarpath'          => '',
993
            'yydecoderpath'      => '',
994
        ];
995
        $fields += $defaults;    // Make sure keys exist to avoid error notices.
996
        ksort($fields);
997
        // Validate settings
998
        $fields['nzbpath'] = Utility::trailingSlash($fields['nzbpath']);
999
        $error = null;
1000
        switch (true) {
1001
            case $fields['mediainfopath'] !== '' && ! is_file($fields['mediainfopath']):
1002
                $error = Settings::ERR_BADMEDIAINFOPATH;
1003
                break;
1004
            case $fields['ffmpegpath'] !== '' && ! is_file($fields['ffmpegpath']):
1005
                $error = Settings::ERR_BADFFMPEGPATH;
1006
                break;
1007
            case $fields['unrarpath'] !== '' && ! is_file($fields['unrarpath']):
1008
                $error = Settings::ERR_BADUNRARPATH;
1009
                break;
1010
            case empty($fields['nzbpath']):
1011
                $error = Settings::ERR_BADNZBPATH_UNSET;
1012
                break;
1013
            case ! file_exists($fields['nzbpath']) || ! is_dir($fields['nzbpath']):
1014
                $error = Settings::ERR_BADNZBPATH;
1015
                break;
1016
            case ! is_readable($fields['nzbpath']):
1017
                $error = Settings::ERR_BADNZBPATH_UNREADABLE;
1018
                break;
1019
            case (int) $fields['checkpasswordedrar'] === 1 && ! is_file($fields['unrarpath']):
1020
                $error = Settings::ERR_DEEPNOUNRAR;
1021
                break;
1022
            case $fields['tmpunrarpath'] !== '' && ! file_exists($fields['tmpunrarpath']):
1023
                $error = Settings::ERR_BADTMPUNRARPATH;
1024
                break;
1025
            case $fields['yydecoderpath'] !== '' &&
1026
                $fields['yydecoderpath'] !== 'simple_php_yenc_decode' &&
1027
                ! file_exists($fields['yydecoderpath']):
1028
                $error = Settings::ERR_BAD_YYDECODER_PATH;
1029
        }
1030
1031
        return $error;
1032
    }
1033
1034
    /**
1035
     * PHP interpretation of MySQL's from_unixtime method.
1036
     * @param int  $utime UnixTime
1037
     *
1038
     * @return string
1039
     */
1040
    public function from_unixtime($utime): string
1041
    {
1042
        return 'FROM_UNIXTIME('.$utime.')';
1043
    }
1044
1045
    /**
1046
     * PHP interpretation of mysql's unix_timestamp method.
1047
     * @param string $date
1048
     *
1049
     * @return int
1050
     */
1051
    public function unix_timestamp($date): int
1052
    {
1053
        return strtotime($date);
1054
    }
1055
1056
    /**
1057
     * Get a string for MySQL with a column name in between
1058
     * ie: UNIX_TIMESTAMP(column_name) AS outputName.
1059
     *
1060
     * @param string $column     The datetime column.
1061
     * @param string $outputName The name to store the SQL data into. (the word after AS)
1062
     *
1063
     * @return string
1064
     */
1065
    public function unix_timestamp_column($column, $outputName = 'unix_time'): string
1066
    {
1067
        return 'UNIX_TIMESTAMP('.$column.') AS '.$outputName;
1068
    }
1069
1070
    /**
1071
     * @return string
1072
     */
1073
    public function uuid(): string
1074
    {
1075
        return Uuid::uuid4()->toString();
1076
    }
1077
1078
    /**
1079
     * Checks whether the connection to the server is working. Optionally restart a new connection.
1080
     * NOTE: Restart does not happen if PDO is not using exceptions (PHP's default configuration).
1081
     * In this case check the return value === false.
1082
     *
1083
     * @param bool $restart Whether an attempt should be made to reinitialise the Db object on failure.
1084
     *
1085
     * @return bool
1086
     * @throws \RuntimeException
1087
     */
1088
    public function ping($restart = false): ?bool
1089
    {
1090
        try {
1091
            return (bool) $this->pdo->query('SELECT 1+1');
1092
        } catch (\PDOException $e) {
1093
            if ($restart === true) {
1094
                $this->initialiseDatabase();
1095
            }
1096
1097
            return false;
1098
        }
1099
    }
1100
1101
    /**
1102
     * Prepares a statement to be run by the Db engine.
1103
     * To run the statement use the returned $statement with ->execute();.
1104
     *
1105
     * Ideally the signature would have array before $options but that causes a strict warning.
1106
     *
1107
     * @param string $query SQL query to run, with optional place holders.
1108
     * @param array $options Driver options.
1109
     *
1110
     * @return false|\PDOstatement on success false on failure.
1111
     *
1112
     * @link http://www.php.net/pdo.prepare.php
1113
     */
1114
    public function Prepare($query, $options = [])
1115
    {
1116
        try {
1117
            $PDOstatement = $this->pdo->prepare($query, $options);
1118
        } catch (\PDOException $e) {
1119
            echo ColorCLI::error("\n".$e->getMessage());
1120
            $PDOstatement = false;
1121
        }
1122
1123
        return $PDOstatement;
1124
    }
1125
1126
    /**
1127
     * Retrieve db attributes http://us3.php.net/manual/en/pdo.getattribute.php.
1128
     *
1129
     * @param int $attribute
1130
     *
1131
     * @return false|mixed
1132
     */
1133
    public function getAttribute($attribute)
1134
    {
1135
        $result = false;
1136
        if ($attribute !== '') {
0 ignored issues
show
introduced by
The condition $attribute !== '' is always true.
Loading history...
1137
            try {
1138
                $result = $this->pdo->getAttribute($attribute);
1139
            } catch (\PDOException $e) {
1140
                echo ColorCLI::error("\n".$e->getMessage());
1141
                $result = false;
1142
            }
1143
        }
1144
1145
        return $result;
1146
    }
1147
1148
    /**
1149
     * Returns the stored Db version string.
1150
     *
1151
     * @return string
1152
     */
1153
    public function getDbVersion(): string
1154
    {
1155
        return $this->dbVersion;
1156
    }
1157
1158
    /**
1159
     * @param string $requiredVersion The minimum version to compare against
1160
     *
1161
     * @return bool|null       TRUE if Db version is greater than or eaqual to $requiredVersion,
1162
     * false if not, and null if the version isn't available to check against.
1163
     */
1164
    public function isDbVersionAtLeast($requiredVersion): ?bool
1165
    {
1166
        if (empty($this->dbVersion)) {
1167
            return null;
1168
        }
1169
1170
        return version_compare($requiredVersion, $this->dbVersion, '<=');
1171
    }
1172
1173
    /**
1174
     * Performs the fetch from the Db server and stores the resulting Major.Minor.Version number.
1175
     */
1176
    private function fetchDbVersion()
1177
    {
1178
        $result = $this->queryOneRow('SELECT VERSION() AS version');
1179
        if (! empty($result)) {
1180
            $dummy = explode('-', $result['version'], 2);
1181
            $this->dbVersion = $dummy[0];
1182
        }
1183
    }
1184
1185
    /**
1186
     * Checks if the query is empty. Cleans the query of whitespace if needed.
1187
     *
1188
     * @param string $query
1189
     *
1190
     * @return bool
1191
     */
1192
    private function parseQuery(&$query): bool
1193
    {
1194
        if (empty($query)) {
1195
            return false;
1196
        }
1197
1198
        return true;
1199
    }
1200
}
1201