Completed
Branch feature/pre-split (7b42f5)
by Anton
03:44
created

PDODriver::setProfiling()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 6
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 3
nc 1
nop 1
dl 0
loc 6
rs 9.4285
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;
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\Exceptions\DriverException;
17
use Spiral\Database\Exceptions\QueryException;
18
use Spiral\Database\Helpers\QueryInterpolator;
19
use Spiral\Database\Injections\Parameter;
20
use Spiral\Database\Injections\ParameterInterface;
21
use Spiral\Database\Query\PDOQuery;
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
     * Query result class.
47
     */
48
    const QUERY_RESULT = PDOQuery::class;
49
50
    /**
51
     * Driver name.
52
     *
53
     * @var string
54
     */
55
    private $name = '';
56
57
    /**
58
     * Transaction level (count of nested transactions). Not all drives can support nested
59
     * transactions.
60
     *
61
     * @var int
62
     */
63
    private $transactionLevel = 0;
64
65
    /**
66
     * @var PDO|null
67
     */
68
    private $pdo = null;
69
70
    /**
71
     * Connection configuration described in DBAL config file. Any driver can be used as data source
72
     * for multiple databases as table prefix and quotation defined on Database instance level.
73
     *
74
     * @var array
75
     */
76
    protected $config = [
77
        'profiling'  => false,
78
79
        //DSN
80
        'connection' => '',
81
        'username'   => '',
82
        'password'   => '',
83
        'options'    => [],
84
    ];
85
86
    /**
87
     * PDO connection options set.
88
     *
89
     * @var array
90
     */
91
    protected $options = [
92
        PDO::ATTR_CASE              => PDO::CASE_NATURAL,
93
        PDO::ATTR_ERRMODE           => PDO::ERRMODE_EXCEPTION,
94
        PDO::ATTR_STRINGIFY_FETCHES => true,
95
    ];
96
97
    /**
98
     * @param string $name
99
     * @param array  $connection
100
     * @throws SugarException
101
     */
102
    public function __construct(string $name, array $connection)
103
    {
104
        $this->name = $name;
105
106
        $this->config = $connection + $this->config;
107
108
        //PDO connection options has to be stored under key "options" of config
109
        $this->options = $connection['options'] + $this->options;
110
    }
111
112
    /**
113
     * Source name, can include database name or database file.
114
     *
115
     * @return string
116
     */
117
    public function getName(): string
118
    {
119
        return $this->name;
120
    }
121
122
    /**
123
     * Get driver source database or file name.
124
     *
125
     * @return string
126
     *
127
     * @throws DriverException
128
     */
129
    public function getSource(): string
130
    {
131
        if (preg_match('/(?:dbname|database)=([^;]+)/i', $this->config['connection'], $matches)) {
132
            return $matches[1];
133
        }
134
135
        throw new DriverException('Unable to locate source name.');
136
    }
137
138
    /**
139
     * Database type driver linked to.
140
     *
141
     * @return string
142
     */
143
    public function getType(): string
144
    {
145
        return static::TYPE;
146
    }
147
148
    /**
149
     * Connection specific timezone, at this moment locked to UTC.
150
     *
151
     * @todo Support connection specific timezones.
152
     *
153
     * @return \DateTimeZone
154
     */
155
    public function getTimezone(): \DateTimeZone
156
    {
157
        return new \DateTimeZone(DatabaseManager::DEFAULT_TIMEZONE);
158
    }
159
160
    /**
161
     * Enabled profiling will raise set of log messages and benchmarks associated with PDO queries.
162
     *
163
     * @param bool $enabled Enable or disable driver profiling.
164
     *
165
     * @return self
166
     */
167
    public function setProfiling(bool $enabled = true): PDODriver
168
    {
169
        $this->config['profiling'] = $enabled;
170
171
        return $this;
172
    }
173
174
    /**
175
     * Check if profiling mode is enabled.
176
     *
177
     * @return bool
178
     */
179
    public function isProfiling(): bool
180
    {
181
        return $this->config['profiling'];
182
    }
183
184
    /**
185
     * Force driver to connect.
186
     *
187
     * @return PDO
188
     *
189
     * @throws DriverException
190
     */
191
    public function connect(): PDO
192
    {
193
        if ($this->isConnected()) {
194
            throw new DriverException("Driver '{$this->name}' already connected");
195
        }
196
197
        $benchmark = $this->benchmark('connect', $this->config['connection']);
198
        try {
199
            $this->pdo = $this->createPDO();
200
        } finally {
201
            $this->benchmark($benchmark);
202
        }
203
204
        return $this->pdo;
205
    }
206
207
    /**
208
     * Disconnect driver.
209
     *
210
     * @return self
211
     */
212
    public function disconnect(): PDODriver
213
    {
214
        $this->pdo = null;
215
216
        return $this;
217
    }
218
219
    /**
220
     * Check if driver already connected.
221
     *
222
     * @return bool
223
     */
224
    public function isConnected(): bool
225
    {
226
        return !empty($this->pdo);
227
    }
228
229
    /**
230
     * Change PDO instance associated with driver.
231
     *
232
     * @param PDO $pdo
233
     *
234
     * @return self
235
     */
236
    public function setPDO(PDO $pdo): PDODriver
237
    {
238
        $this->pdo = $pdo;
239
240
        return $this;
241
    }
242
243
    /**
244
     * Get associated PDO connection. Will automatically connect if such connection does not exists.
245
     *
246
     * @return PDO
247
     */
248
    public function getPDO(): PDO
249
    {
250
        if (!$this->isConnected()) {
251
            $this->connect();
252
        }
253
254
        return $this->pdo;
255
    }
256
257
    /**
258
     * Driver specific database/table identifier quotation.
259
     *
260
     * @param string $identifier
261
     *
262
     * @return string
263
     */
264
    public function identifier(string $identifier): string
265
    {
266
        return $identifier == '*' ? '*' : '"' . str_replace('"', '""', $identifier) . '"';
267
    }
268
269
    /**
270
     * Wraps PDO query method with custom representation class.
271
     *
272
     * @param string $statement
273
     * @param array  $parameters
274
     * @param string $class Class name to be used, by default driver specific query result to be
275
     *                      used.
276
     * @param array  $args  Class construction arguments (by default filtered parameters).
277
     *
278
     * @return \PDOStatement|PDOQuery
279
     */
280
    public function query(
281
        string $statement,
282
        array $parameters = [],
283
        $class = null,
284
        array $args = []
285
    ): \PDOStatement {
286
        return $this->statement($statement, $parameters, $class ?? static::QUERY_RESULT, $args);
287
    }
288
289
    /**
290
     * Create instance of PDOStatement using provided SQL query and set of parameters and execute
291
     * it.
292
     *
293
     * @param string $query
294
     * @param array  $parameters Parameters to be binded into query.
295
     * @param string $class      Class to represent PDO statement.
296
     * @param array  $args       Class construction arguments (by default filtered parameters)
297
     *
298
     * @return \PDOStatement
299
     *
300
     * @throws QueryException
301
     */
302
    public function statement(
303
        string $query,
304
        array $parameters = [],
305
        string $class = 'PDOStatement',
306
        array $args = []
307
    ): \PDOStatement {
308
        try {
309
            //Filtered and normalized parameters
310
            $parameters = $this->flattenParameters($parameters);
311
312
            if ($this->isProfiling()) {
313
                $queryString = QueryInterpolator::interpolate($query, $parameters);
314
                $benchmark = $this->benchmark($this->name, $queryString);
315
            }
316
317
            //PDOStatement instance (prepared)
318
            $pdoStatement = $this->prepare($query, $class, !empty($args) ? $args : [$parameters]);
319
320
            //Mounting all input parameters
321
            $pdoStatement = $this->bindParameters($pdoStatement, $parameters);
322
323
            try {
324
                $pdoStatement->execute();
325
            } finally {
326
                if (!empty($benchmark)) {
327
                    $this->benchmark($benchmark);
328
                }
329
            }
330
331
            //Only exists if profiling on
332
            if (!empty($queryString)) {
333
                $this->logger()->info($queryString, compact('query', 'parameters'));
334
            }
335
336
        } catch (\PDOException $e) {
337
            if (empty($queryString)) {
338
                $queryString = QueryInterpolator::interpolate($query, $parameters);
339
            }
340
341
            //Logging error even when no profiling is enabled
342
            $this->logger()->error($queryString, compact('query', 'parameters'));
343
344
            //Converting exception into query or integrity exception
345
            throw $this->clarifyException($e);
346
        }
347
348
        return $pdoStatement;
349
    }
350
351
    /**
352
     * Get prepared PDO statement.
353
     *
354
     * @param string $statement Query statement.
355
     * @param string $class     Class to represent PDO statement.
356
     * @param array  $args      Class construction arguments (by default paramaters)
357
     *
358
     * @return \PDOStatement
359
     */
360
    public function prepare(
361
        string $statement,
362
        string $class = 'PDOStatement',
363
        array $args = []
364
    ): \PDOStatement {
365
        $pdo = $this->getPDO();
366
        $pdo->setAttribute(PDO::ATTR_STATEMENT_CLASS, [$class, $args]);
367
368
        return $this->getPDO()->prepare($statement);
369
    }
370
371
    /**
372
     * Get id of last inserted row, this method must be called after insert query. Attention,
373
     * such functionality may not work in some DBMS property (Postgres).
374
     *
375
     * @param string|null $sequence Name of the sequence object from which the ID should be
376
     *                              returned.
377
     *
378
     * @return mixed
379
     */
380
    public function lastInsertID(string $sequence = null)
381
    {
382
        return $sequence
383
            ? (int)$this->getPDO()->lastInsertId($sequence)
384
            : (int)$this->getPDO()->lastInsertId();
385
    }
386
387
    /**
388
     * Prepare set of query builder/user parameters to be send to PDO. Must convert DateTime
389
     * instances into valid database timestamps and resolve values of ParameterInterface.
390
     *
391
     * Every value has to wrapped with parameter interface.
392
     *
393
     * @param array $parameters
394
     *
395
     * @return ParameterInterface[]
396
     *
397
     * @throws DriverException
398
     */
399
    public function flattenParameters(array $parameters): array
400
    {
401
        $flatten = [];
402
        foreach ($parameters as $key => $parameter) {
403
            if (!$parameter instanceof ParameterInterface) {
404
                //Let's wrap value
405
                $parameter = new Parameter($parameter, Parameter::DETECT_TYPE);
406
            }
407
408
            if ($parameter->isArray()) {
409
                if (!is_numeric($key)) {
410
                    throw new DriverException("Array parameters can not be named");
411
                }
412
413
                //Flattening arrays
414
                $nestedParameters = $parameter->flatten();
415
416
                /**
417
                 * @var ParameterInterface $parameter []
418
                 */
419
                foreach ($nestedParameters as &$nestedParameter) {
420
                    if ($nestedParameter->getValue() instanceof \DateTime) {
421
422
                        //Original parameter must not be altered
423
                        $nestedParameter = $nestedParameter->withValue(
424
                            $this->resolveDateTime($nestedParameter->getValue())
425
                        );
426
                    }
427
428
                    unset($nestedParameter);
429
                }
430
431
                //Quick and dirty
432
                $flatten = array_merge($flatten, $nestedParameters);
433
434
            } else {
435
                if ($parameter->getValue() instanceof \DateTime) {
436
                    //Original parameter must not be altered
437
                    $parameter = $parameter->withValue(
438
                        $this->resolveDateTime($parameter->getValue())
439
                    );
440
                }
441
442
                if (is_numeric($key)) {
443
                    //Numeric keys can be shifted
444
                    $flatten[] = $parameter;
445
                } else {
446
                    $flatten[$key] = $parameter;
447
                }
448
            }
449
        }
450
451
        return $flatten;
452
    }
453
454
    /**
455
     * Start SQL transaction with specified isolation level (not all DBMS support it). Nested
456
     * transactions are processed using savepoints.
457
     *
458
     * @link   http://en.wikipedia.org/wiki/Database_transaction
459
     * @link   http://en.wikipedia.org/wiki/Isolation_(database_systems)
460
     *
461
     * @param string $isolationLevel
462
     *
463
     * @return bool
464
     */
465
    public function beginTransaction(string $isolationLevel = null): bool
466
    {
467
        ++$this->transactionLevel;
468
469
        if ($this->transactionLevel == 1) {
470
            if (!empty($isolationLevel)) {
471
                $this->isolationLevel($isolationLevel);
472
            }
473
474
            if ($this->isProfiling()) {
475
                $this->logger()->info('Begin transaction');
476
            }
477
478
            return $this->getPDO()->beginTransaction();
479
        }
480
481
        $this->savepointCreate($this->transactionLevel);
482
483
        return true;
484
    }
485
486
    /**
487
     * Commit the active database transaction.
488
     *
489
     * @return bool
490
     */
491 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...
492
    {
493
        --$this->transactionLevel;
494
495
        if ($this->transactionLevel == 0) {
496
            if ($this->isProfiling()) {
497
                $this->logger()->info('Commit transaction');
498
            }
499
500
            return $this->getPDO()->commit();
501
        }
502
503
        $this->savepointRelease($this->transactionLevel + 1);
504
505
        return true;
506
    }
507
508
    /**
509
     * Rollback the active database transaction.
510
     *
511
     * @return bool
512
     */
513 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...
514
    {
515
        --$this->transactionLevel;
516
517
        if ($this->transactionLevel == 0) {
518
            if ($this->isProfiling()) {
519
                $this->logger()->info('Rollback transaction');
520
            }
521
522
            return $this->getPDO()->rollBack();
523
        }
524
525
        $this->savepointRollback($this->transactionLevel + 1);
526
527
        return true;
528
    }
529
530
    /**
531
     * @return array
532
     */
533
    public function __debugInfo()
534
    {
535
        return [
536
            'connection' => $this->config['connection'],
537
            'connected'  => $this->isConnected(),
538
            'profiling'  => $this->isProfiling(),
539
            'source'     => $this->getSource(),
540
            'options'    => $this->options,
541
        ];
542
    }
543
544
    /**
545
     * Create instance of configured PDO class.
546
     *
547
     * @return PDO
548
     */
549
    protected function createPDO(): PDO
550
    {
551
        return new PDO(
552
            $this->config['connection'],
553
            $this->config['username'],
554
            $this->config['password'],
555
            $this->options
556
        );
557
    }
558
559
    /**
560
     * Convert PDO exception into query or integrity exception.
561
     *
562
     * @param \PDOException $exception
563
     *
564
     * @return QueryException
565
     */
566
    protected function clarifyException(\PDOException $exception): QueryException
567
    {
568
        //@todo more exceptions to be thrown
569
        return new QueryException($exception);
570
    }
571
572
    /**
573
     * Set transaction isolation level, this feature may not be supported by specific database
574
     * driver.
575
     *
576
     * @param string $level
577
     */
578
    protected function isolationLevel(string $level)
579
    {
580
        if ($this->isProfiling()) {
581
            $this->logger()->info("Set transaction isolation level to '{$level}'");
582
        }
583
584
        if (!empty($level)) {
585
            $this->statement("SET TRANSACTION ISOLATION LEVEL {$level}");
586
        }
587
    }
588
589
    /**
590
     * Create nested transaction save point.
591
     *
592
     * @link http://en.wikipedia.org/wiki/Savepoint
593
     *
594
     * @param string $name Savepoint name/id, must not contain spaces and be valid database
595
     *                     identifier.
596
     */
597 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...
598
    {
599
        if ($this->isProfiling()) {
600
            $this->logger()->info("Creating savepoint '{$name}'");
601
        }
602
603
        $this->statement('SAVEPOINT ' . $this->identifier("SVP{$name}"));
604
    }
605
606
    /**
607
     * Commit/release savepoint.
608
     *
609
     * @link http://en.wikipedia.org/wiki/Savepoint
610
     *
611
     * @param string $name Savepoint name/id, must not contain spaces and be valid database
612
     *                     identifier.
613
     */
614 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...
615
    {
616
        if ($this->isProfiling()) {
617
            $this->logger()->info("Releasing savepoint '{$name}'");
618
        }
619
620
        $this->statement('RELEASE SAVEPOINT ' . $this->identifier("SVP{$name}"));
621
    }
622
623
    /**
624
     * Rollback savepoint.
625
     *
626
     * @link http://en.wikipedia.org/wiki/Savepoint
627
     *
628
     * @param string $name Savepoint name/id, must not contain spaces and be valid database
629
     *                     identifier.
630
     */
631 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...
632
    {
633
        if ($this->isProfiling()) {
634
            $this->logger()->info("Rolling back savepoint '{$name}'");
635
        }
636
        $this->statement('ROLLBACK TO SAVEPOINT ' . $this->identifier("SVP{$name}"));
637
    }
638
639
    /**
640
     * Convert DateTime object into local database representation. Driver will automatically force
641
     * needed timezone.
642
     *
643
     * @param \DateTime $dateTime
644
     *
645
     * @return string
646
     */
647
    protected function resolveDateTime(\DateTime $dateTime): string
648
    {
649
        return $dateTime->setTimezone($this->getTimezone())->format(static::DATETIME);
650
    }
651
652
    /**
653
     * Bind parameters into statement.
654
     *
655
     * @param \PDOStatement        $statement
656
     * @param ParameterInterface[] $parameters Named hash of ParameterInterface.
657
     * @return \PDOStatement
658
     */
659
    private function bindParameters(\PDOStatement $statement, array $parameters): \PDOStatement
660
    {
661
        foreach ($parameters as $index => $parameter) {
662
            if (is_numeric($index)) {
663
                //Numeric, @see http://php.net/manual/en/pdostatement.bindparam.php
664
                $statement->bindValue($index + 1, $parameter->getValue(), $parameter->getType());
665
            } else {
666
                //Named
667
                $statement->bindValue($index, $parameter->getValue(), $parameter->getType());
668
            }
669
        }
670
671
        return $statement;
672
    }
673
}
674