Completed
Branch feature/pre-split (f8e7b8)
by Anton
04:02
created

PDODriver::getTimezone()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 2
nc 1
nop 0
dl 0
loc 4
rs 10
c 0
b 0
f 0
1
<?php
2
/**
3
 * Spiral Framework.
4
 *
5
 * @license   MIT
6
 * @author    Anton Titov (Wolfy-J)
7
 */
8
9
namespace Spiral\Database\Entities\Prototypes;
10
11
use PDO;
12
use Psr\Log\LoggerAwareInterface;
13
use Spiral\Core\Component;
14
use Spiral\Core\Exceptions\SugarException;
15
use Spiral\Database\DatabaseManager;
16
use Spiral\Database\Entities\Query\PDOResult;
17
use Spiral\Database\Exceptions\DriverException;
18
use Spiral\Database\Exceptions\QueryException;
19
use Spiral\Database\Helpers\QueryInterpolator;
20
use Spiral\Database\Injections\Parameter;
21
use Spiral\Database\Injections\ParameterInterface;
22
use Spiral\Debug\Traits\BenchmarkTrait;
23
use Spiral\Debug\Traits\LoggerTrait;
24
25
/**
26
 * Basic implementation of DBAL Driver, basically decorates PDO. Extends component to provide access
27
 *  to functionality like shared loggers and benchmarking.
28
 */
29
abstract class PDODriver extends Component implements LoggerAwareInterface
30
{
31
    use LoggerTrait, BenchmarkTrait;
32
33
    /**
34
     * One of DatabaseInterface types, must be set on implementation.
35
     */
36
    const TYPE = null;
37
38
    /**
39
     * DateTime format to be used to perform automatic conversion of DateTime objects.
40
     *
41
     * @var string
42
     */
43
    const DATETIME = 'Y-m-d H:i:s';
44
45
    /**
46
     * Driver name.
47
     *
48
     * @var string
49
     */
50
    private $name = '';
51
52
    /**
53
     * Transaction level (count of nested transactions). Not all drives can support nested
54
     * transactions.
55
     *
56
     * @var int
57
     */
58
    private $transactionLevel = 0;
59
60
    /**
61
     * @var PDO|null
62
     */
63
    private $pdo = null;
64
65
    /**
66
     * Connection configuration described in DBAL config file. Any driver can be used as data source
67
     * for multiple databases as table prefix and quotation defined on Database instance level.
68
     *
69
     * @var array
70
     */
71
    protected $defaultOptions = [
72
        'profiling'  => false,
73
74
        //DSN
75
        'connection' => '',
76
        'username'   => '',
77
        'password'   => '',
78
        'options'    => [],
79
    ];
80
81
    /**
82
     * PDO connection options set.
83
     *
84
     * @var array
85
     */
86
    protected $options = [
87
        PDO::ATTR_CASE              => PDO::CASE_NATURAL,
88
        PDO::ATTR_ERRMODE           => PDO::ERRMODE_EXCEPTION,
89
        PDO::ATTR_STRINGIFY_FETCHES => true,
90
    ];
91
92
    /**
93
     * @param string $name
94
     * @param array  $options
95
     *
96
     * @throws SugarException
97
     */
98
    public function __construct(string $name, array $options)
99
    {
100
        $this->name = $name;
101
102
        $this->defaultOptions = $options + $this->defaultOptions;
103
104
        //PDO connection options has to be stored under key "options" of config
105
        $this->options = $options['options'] + $this->options;
106
    }
107
108
    /**
109
     * Source name, can include database name or database file.
110
     *
111
     * @return string
112
     */
113
    public function getName(): string
114
    {
115
        return $this->name;
116
    }
117
118
    /**
119
     * Get driver source database or file name.
120
     *
121
     * @return string
122
     *
123
     * @throws DriverException
124
     */
125
    public function getSource(): string
126
    {
127
        if (preg_match('/(?:dbname|database)=([^;]+)/i', $this->defaultOptions['connection'],
128
            $matches)) {
129
            return $matches[1];
130
        }
131
132
        throw new DriverException('Unable to locate source name.');
0 ignored issues
show
Unused Code introduced by
The call to DriverException::__construct() has too many arguments starting with 'Unable to locate source name.'.

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress.

In this case you can add the @ignore PhpDoc annotation to the duplicate definition and it will be ignored.

Loading history...
133
    }
134
135
    /**
136
     * Database type driver linked to.
137
     *
138
     * @return string
139
     */
140
    public function getType(): string
141
    {
142
        return static::TYPE;
143
    }
144
145
    /**
146
     * Connection specific timezone, at this moment locked to UTC.
147
     *
148
     * @todo Support connection specific timezones.
149
     *
150
     * @return \DateTimeZone
151
     */
152
    public function getTimezone(): \DateTimeZone
153
    {
154
        return new \DateTimeZone(DatabaseManager::DEFAULT_TIMEZONE);
155
    }
156
157
    /**
158
     * Enabled profiling will raise set of log messages and benchmarks associated with PDO queries.
159
     *
160
     * @param bool $enabled Enable or disable driver profiling.
161
     *
162
     * @return self
163
     */
164
    public function setProfiling(bool $enabled = true): PDODriver
165
    {
166
        $this->defaultOptions['profiling'] = $enabled;
167
168
        return $this;
169
    }
170
171
    /**
172
     * Check if profiling mode is enabled.
173
     *
174
     * @return bool
175
     */
176
    public function isProfiling(): bool
177
    {
178
        return $this->defaultOptions['profiling'];
179
    }
180
181
    /**
182
     * Force driver to connect.
183
     *
184
     * @return PDO
185
     *
186
     * @throws DriverException
187
     */
188
    public function connect(): PDO
189
    {
190
        if ($this->isConnected()) {
191
            throw new DriverException("Driver '{$this->name}' already connected");
0 ignored issues
show
Unused Code introduced by
The call to DriverException::__construct() has too many arguments starting with "Driver '{$this->name}' already connected".

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress.

In this case you can add the @ignore PhpDoc annotation to the duplicate definition and it will be ignored.

Loading history...
192
        }
193
194
        $benchmark = $this->benchmark('connect', $this->defaultOptions['connection']);
195
        try {
196
            $this->pdo = $this->createPDO();
197
        } finally {
198
            $this->benchmark($benchmark);
199
        }
200
201
        return $this->pdo;
202
    }
203
204
    /**
205
     * Disconnect driver.
206
     *
207
     * @return self
208
     */
209
    public function disconnect(): PDODriver
210
    {
211
        $this->pdo = null;
212
213
        return $this;
214
    }
215
216
    /**
217
     * Check if driver already connected.
218
     *
219
     * @return bool
220
     */
221
    public function isConnected(): bool
222
    {
223
        return !empty($this->pdo);
224
    }
225
226
    /**
227
     * Change PDO instance associated with driver.
228
     *
229
     * @param PDO $pdo
230
     *
231
     * @return self
232
     */
233
    public function setPDO(PDO $pdo): PDODriver
234
    {
235
        $this->pdo = $pdo;
236
237
        return $this;
238
    }
239
240
    /**
241
     * Get associated PDO connection. Will automatically connect if such connection does not exists.
242
     *
243
     * @return PDO
244
     */
245
    public function getPDO(): PDO
246
    {
247
        if (!$this->isConnected()) {
248
            $this->connect();
249
        }
250
251
        return $this->pdo;
252
    }
253
254
    /**
255
     * Driver specific database/table identifier quotation.
256
     *
257
     * @param string $identifier
258
     *
259
     * @return string
260
     */
261
    public function identifier(string $identifier): string
262
    {
263
        return $identifier == '*' ? '*' : '"' . str_replace('"', '""', $identifier) . '"';
264
    }
265
266
    /**
267
     * Wraps PDO query method with custom representation class.
268
     *
269
     * @param string $statement
270
     * @param array  $parameters
271
     *
272
     * @return PDOResult
273
     */
274
    public function query(string $statement, array $parameters = []): PDOResult
275
    {
276
        //Forcing specific return class
277
        $result = $this->statement($statement, $parameters, PDOResult::class);
278
279
        /**
280
         * @var PDOResult $result
281
         */
282
        return $result;
283
    }
284
285
    /**
286
     * Create instance of PDOStatement using provided SQL query and set of parameters and execute
287
     * it.
288
     *
289
     * @param string $query
290
     * @param array  $parameters Parameters to be binded into query.
291
     * @param string $class      Class to be used to represent results.
292
     * @param array  $args       Class construction arguments (by default filtered parameters)
293
     *
294
     * @return \PDOStatement
295
     *
296
     * @throws QueryException
297
     */
298
    public function statement(
299
        string $query,
300
        array $parameters = [],
301
        $class = PDOResult::class,
302
        array $args = []
303
    ): \PDOStatement {
304
        try {
305
            //Filtered and normalized parameters
306
            $parameters = $this->flattenParameters($parameters);
307
308
            if ($this->isProfiling()) {
309
                $queryString = QueryInterpolator::interpolate($query, $parameters);
310
                $benchmark = $this->benchmark($this->name, $queryString);
311
            }
312
313
            //PDOStatement instance (prepared)
314
            $pdoStatement = $this->prepare($query, $class, !empty($args) ? $args : [$parameters]);
315
316
            //Mounting all input parameters
317
            $pdoStatement = $this->bindParameters($pdoStatement, $parameters);
318
319
            try {
320
                $pdoStatement->execute();
321
            } finally {
322
                if (!empty($benchmark)) {
323
                    $this->benchmark($benchmark);
324
                }
325
            }
326
327
            //Only exists if profiling on
328
            if (!empty($queryString)) {
329
                $this->logger()->info($queryString, compact('query', 'parameters'));
330
            }
331
332
        } catch (\PDOException $e) {
333
            if (empty($queryString)) {
334
                $queryString = QueryInterpolator::interpolate($query, $parameters);
335
            }
336
337
            //Logging error even when no profiling is enabled
338
            $this->logger()->error($queryString, compact('query', 'parameters'));
339
340
            //Converting exception into query or integrity exception
341
            throw $this->clarifyException($e);
342
        }
343
344
        return $pdoStatement;
345
    }
346
347
    /**
348
     * Get prepared PDO statement.
349
     *
350
     * @param string $statement Query statement.
351
     * @param string $class     Class to represent PDO statement.
352
     * @param array  $args      Class construction arguments (by default paramaters)
353
     *
354
     * @return \PDOStatement
355
     */
356
    public function prepare(
357
        string $statement,
358
        $class = \PDOStatement::class,
359
        array $args = []
360
    ): \PDOStatement {
361
        $pdo = $this->getPDO();
362
363
        $pdo->setAttribute(PDO::ATTR_STATEMENT_CLASS, [$class, $args]);
364
365
        return $pdo->prepare($statement);
366
    }
367
368
    /**
369
     * Get id of last inserted row, this method must be called after insert query. Attention,
370
     * such functionality may not work in some DBMS property (Postgres).
371
     *
372
     * @param string|null $sequence Name of the sequence object from which the ID should be
373
     *                              returned.
374
     *
375
     * @return mixed
376
     */
377
    public function lastInsertID(string $sequence = null)
378
    {
379
        $pdo = $this->getPDO();
380
381
        return $sequence ? (int)$pdo->lastInsertId($sequence) : (int)$pdo->lastInsertId();
382
    }
383
384
    /**
385
     * Prepare set of query builder/user parameters to be send to PDO. Must convert DateTime
386
     * instances into valid database timestamps and resolve values of ParameterInterface.
387
     *
388
     * Every value has to wrapped with parameter interface.
389
     *
390
     * @param array $parameters
391
     *
392
     * @return ParameterInterface[]
393
     *
394
     * @throws DriverException
395
     */
396
    public function flattenParameters(array $parameters): array
397
    {
398
        $flatten = [];
399
        foreach ($parameters as $key => $parameter) {
400
            if (!$parameter instanceof ParameterInterface) {
401
                //Let's wrap value
402
                $parameter = new Parameter($parameter, Parameter::DETECT_TYPE);
403
            }
404
405
            if ($parameter->isArray()) {
406
                if (!is_numeric($key)) {
407
                    throw new DriverException("Array parameters can not be named");
0 ignored issues
show
Unused Code introduced by
The call to DriverException::__construct() has too many arguments starting with 'Array parameters can not be named'.

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress.

In this case you can add the @ignore PhpDoc annotation to the duplicate definition and it will be ignored.

Loading history...
408
                }
409
410
                //Flattening arrays
411
                $nestedParameters = $parameter->flatten();
412
413
                /**
414
                 * @var ParameterInterface $parameter []
415
                 */
416
                foreach ($nestedParameters as &$nestedParameter) {
417
                    if ($nestedParameter->getValue() instanceof \DateTime) {
418
419
                        //Original parameter must not be altered
420
                        $nestedParameter = $nestedParameter->withValue(
421
                            $this->resolveDateTime($nestedParameter->getValue())
422
                        );
423
                    }
424
425
                    unset($nestedParameter);
426
                }
427
428
                //Quick and dirty
429
                $flatten = array_merge($flatten, $nestedParameters);
430
431
            } else {
432
                if ($parameter->getValue() instanceof \DateTime) {
433
                    //Original parameter must not be altered
434
                    $parameter = $parameter->withValue(
435
                        $this->resolveDateTime($parameter->getValue())
436
                    );
437
                }
438
439
                if (is_numeric($key)) {
440
                    //Numeric keys can be shifted
441
                    $flatten[] = $parameter;
442
                } else {
443
                    $flatten[$key] = $parameter;
444
                }
445
            }
446
        }
447
448
        return $flatten;
449
    }
450
451
    /**
452
     * Start SQL transaction with specified isolation level (not all DBMS support it). Nested
453
     * transactions are processed using savepoints.
454
     *
455
     * @link   http://en.wikipedia.org/wiki/Database_transaction
456
     * @link   http://en.wikipedia.org/wiki/Isolation_(database_systems)
457
     *
458
     * @param string $isolationLevel
459
     *
460
     * @return bool
461
     */
462
    public function beginTransaction(string $isolationLevel = null): bool
463
    {
464
        ++$this->transactionLevel;
465
466
        if ($this->transactionLevel == 1) {
467
            if (!empty($isolationLevel)) {
468
                $this->isolationLevel($isolationLevel);
469
            }
470
471
            if ($this->isProfiling()) {
472
                $this->logger()->info('Begin transaction');
473
            }
474
475
            return $this->getPDO()->beginTransaction();
476
        }
477
478
        $this->savepointCreate($this->transactionLevel);
479
480
        return true;
481
    }
482
483
    /**
484
     * Commit the active database transaction.
485
     *
486
     * @return bool
487
     */
488 View Code Duplication
    public function commitTransaction(): bool
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
489
    {
490
        --$this->transactionLevel;
491
492
        if ($this->transactionLevel == 0) {
493
            if ($this->isProfiling()) {
494
                $this->logger()->info('Commit transaction');
495
            }
496
497
            return $this->getPDO()->commit();
498
        }
499
500
        $this->savepointRelease($this->transactionLevel + 1);
501
502
        return true;
503
    }
504
505
    /**
506
     * Rollback the active database transaction.
507
     *
508
     * @return bool
509
     */
510 View Code Duplication
    public function rollbackTransaction(): bool
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
511
    {
512
        --$this->transactionLevel;
513
514
        if ($this->transactionLevel == 0) {
515
            if ($this->isProfiling()) {
516
                $this->logger()->info('Rollback transaction');
517
            }
518
519
            return $this->getPDO()->rollBack();
520
        }
521
522
        $this->savepointRollback($this->transactionLevel + 1);
523
524
        return true;
525
    }
526
527
    /**
528
     * @return array
529
     */
530
    public function __debugInfo()
531
    {
532
        return [
533
            'connection' => $this->defaultOptions['connection'],
534
            'connected'  => $this->isConnected(),
535
            'profiling'  => $this->isProfiling(),
536
            'source'     => $this->getSource(),
537
            'options'    => $this->options,
538
        ];
539
    }
540
541
    /**
542
     * Create instance of configured PDO class.
543
     *
544
     * @return PDO
545
     */
546
    protected function createPDO(): PDO
547
    {
548
        return new PDO(
549
            $this->defaultOptions['connection'],
550
            $this->defaultOptions['username'],
551
            $this->defaultOptions['password'],
552
            $this->options
553
        );
554
    }
555
556
    /**
557
     * Convert PDO exception into query or integrity exception.
558
     *
559
     * @param \PDOException $exception
560
     *
561
     * @return QueryException
562
     */
563
    protected function clarifyException(\PDOException $exception): QueryException
564
    {
565
        //@todo more exceptions to be thrown
566
        return new QueryException($exception);
567
    }
568
569
    /**
570
     * Set transaction isolation level, this feature may not be supported by specific database
571
     * driver.
572
     *
573
     * @param string $level
574
     */
575
    protected function isolationLevel(string $level)
576
    {
577
        if ($this->isProfiling()) {
578
            $this->logger()->info("Set transaction isolation level to '{$level}'");
579
        }
580
581
        if (!empty($level)) {
582
            $this->statement("SET TRANSACTION ISOLATION LEVEL {$level}");
583
        }
584
    }
585
586
    /**
587
     * Create nested transaction save point.
588
     *
589
     * @link http://en.wikipedia.org/wiki/Savepoint
590
     *
591
     * @param string $name Savepoint name/id, must not contain spaces and be valid database
592
     *                     identifier.
593
     */
594 View Code Duplication
    protected function savepointCreate(string $name)
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
595
    {
596
        if ($this->isProfiling()) {
597
            $this->logger()->info("Creating savepoint '{$name}'");
598
        }
599
600
        $this->statement('SAVEPOINT ' . $this->identifier("SVP{$name}"));
601
    }
602
603
    /**
604
     * Commit/release savepoint.
605
     *
606
     * @link http://en.wikipedia.org/wiki/Savepoint
607
     *
608
     * @param string $name Savepoint name/id, must not contain spaces and be valid database
609
     *                     identifier.
610
     */
611 View Code Duplication
    protected function savepointRelease(string $name)
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
612
    {
613
        if ($this->isProfiling()) {
614
            $this->logger()->info("Releasing savepoint '{$name}'");
615
        }
616
617
        $this->statement('RELEASE SAVEPOINT ' . $this->identifier("SVP{$name}"));
618
    }
619
620
    /**
621
     * Rollback savepoint.
622
     *
623
     * @link http://en.wikipedia.org/wiki/Savepoint
624
     *
625
     * @param string $name Savepoint name/id, must not contain spaces and be valid database
626
     *                     identifier.
627
     */
628 View Code Duplication
    protected function savepointRollback(string $name)
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
629
    {
630
        if ($this->isProfiling()) {
631
            $this->logger()->info("Rolling back savepoint '{$name}'");
632
        }
633
        $this->statement('ROLLBACK TO SAVEPOINT ' . $this->identifier("SVP{$name}"));
634
    }
635
636
    /**
637
     * Convert DateTime object into local database representation. Driver will automatically force
638
     * needed timezone.
639
     *
640
     * @param \DateTime $dateTime
641
     *
642
     * @return string
643
     */
644
    protected function resolveDateTime(\DateTime $dateTime): string
645
    {
646
        return $dateTime->setTimezone($this->getTimezone())->format(static::DATETIME);
647
    }
648
649
    /**
650
     * Bind parameters into statement.
651
     *
652
     * @param \PDOStatement        $statement
653
     * @param ParameterInterface[] $parameters Named hash of ParameterInterface.
654
     *
655
     * @return \PDOStatement
656
     */
657
    private function bindParameters(\PDOStatement $statement, array $parameters): \PDOStatement
658
    {
659
        foreach ($parameters as $index => $parameter) {
660
            if (is_numeric($index)) {
661
                //Numeric, @see http://php.net/manual/en/pdostatement.bindparam.php
662
                $statement->bindValue($index + 1, $parameter->getValue(), $parameter->getType());
663
            } else {
664
                //Named
665
                $statement->bindValue($index, $parameter->getValue(), $parameter->getType());
666
            }
667
        }
668
669
        return $statement;
670
    }
671
}
672