Completed
Branch feature/pre-split (f4fa2c)
by Anton
03:14
created

PDODriver::__debugInfo()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 10
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 7
nc 1
nop 0
dl 0
loc 10
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\ScopeException;
15
use Spiral\Database\Exceptions\DriverException;
16
use Spiral\Database\Exceptions\QueryException;
17
use Spiral\Database\Helpers\QueryInterpolator;
18
use Spiral\Database\Injections\Parameter;
19
use Spiral\Database\Injections\ParameterInterface;
20
use Spiral\Debug\Traits\BenchmarkTrait;
21
use Spiral\Debug\Traits\LoggerTrait;
22
23
/**
24
 * Basic implementation of DBAL Driver, basically decorates PDO. Extends component to provide access
25
 *  to functionality like shared loggers and benchmarking.
26
 */
27
abstract class PDODriver extends Component implements LoggerAwareInterface
28
{
29
    use LoggerTrait, BenchmarkTrait;
30
31
    /**
32
     * One of DatabaseInterface types, must be set on implementation.
33
     */
34
    const TYPE = null;
35
36
    /**
37
     * DateTime format to be used to perform automatic conversion of DateTime objects.
38
     *
39
     * @var string
40
     */
41
    const DATETIME = 'Y-m-d H:i:s';
42
43
    /**
44
     * Driver name.
45
     *
46
     * @var string
47
     */
48
    private $name = '';
49
50
    /**
51
     * @var PDO|null
52
     */
53
    private $pdo = null;
54
55
    /**
56
     * Connection configuration described in DBAL config file. Any driver can be used as data source
57
     * for multiple databases as table prefix and quotation defined on Database instance level.
58
     *
59
     * @var array
60
     */
61
    protected $options = [
62
        'profiling'  => false,
63
64
        //All datetime objects will be converted relative to this timezone
65
        'timezone'   => 'UTC',
66
67
        //DSN
68
        'connection' => '',
69
        'username'   => '',
70
        'password'   => '',
71
        'options'    => [],
72
    ];
73
74
    /**
75
     * PDO connection options set.
76
     *
77
     * @var array
78
     */
79
    protected $pdoOptions = [
80
        PDO::ATTR_CASE              => PDO::CASE_NATURAL,
81
        PDO::ATTR_ERRMODE           => PDO::ERRMODE_EXCEPTION,
82
        PDO::ATTR_STRINGIFY_FETCHES => true,
83
    ];
84
85
    /**
86
     * @param string $name
87
     * @param array  $options
88
     *
89
     * @throws ScopeException
90
     */
91
    public function __construct(string $name, array $options)
92
    {
93
        $this->name = $name;
94
95
        $this->options = $options + $this->options;
96
97
        if (!empty($options['options'])) {
98
            //PDO connection options has to be stored under key "options" of config
99
            $this->pdoOptions = $options['options'] + $this->pdoOptions;
100
        }
101
    }
102
103
    /**
104
     * Source name, can include database name or database file.
105
     *
106
     * @return string
107
     */
108
    public function getName(): string
109
    {
110
        return $this->name;
111
    }
112
113
    /**
114
     * Get driver source database or file name.
115
     *
116
     * @return string
117
     *
118
     * @throws DriverException
119
     */
120
    public function getSource(): string
121
    {
122
        if (preg_match('/(?:dbname|database)=([^;]+)/i', $this->options['connection'],
123
            $matches)) {
124
            return $matches[1];
125
        }
126
127
        throw new DriverException('Unable to locate source name.');
128
    }
129
130
    /**
131
     * Database type driver linked to.
132
     *
133
     * @return string
134
     */
135
    public function getType(): string
136
    {
137
        return static::TYPE;
138
    }
139
140
    /**
141
     * Connection specific timezone, at this moment locked to UTC.
142
     *
143
     * @return \DateTimeZone
144
     */
145
    public function getTimezone(): \DateTimeZone
146
    {
147
        return new \DateTimeZone($this->options['timezone']);
148
    }
149
150
    /**
151
     * Enabled profiling will raise set of log messages and benchmarks associated with PDO queries.
152
     *
153
     * @param bool $enabled Enable or disable driver profiling.
154
     *
155
     * @return self
156
     */
157
    public function setProfiling(bool $enabled = true): PDODriver
158
    {
159
        $this->options['profiling'] = $enabled;
160
161
        return $this;
162
    }
163
164
    /**
165
     * Check if profiling mode is enabled.
166
     *
167
     * @return bool
168
     */
169
    public function isProfiling(): bool
170
    {
171
        return $this->options['profiling'];
172
    }
173
174
    /**
175
     * Force driver to connect.
176
     *
177
     * @return PDO
178
     *
179
     * @throws DriverException
180
     */
181
    public function connect(): PDO
182
    {
183
        if ($this->isConnected()) {
184
            return $this->pdo;
185
        }
186
187
        $benchmark = $this->benchmark('connect', $this->options['connection']);
188
        try {
189
            $this->pdo = $this->createPDO();
190
        } finally {
191
            $this->benchmark($benchmark);
192
        }
193
194
        return $this->pdo;
195
    }
196
197
    /**
198
     * Disconnect driver.
199
     *
200
     * @return self
201
     */
202
    public function disconnect(): PDODriver
203
    {
204
        $this->pdo = null;
205
206
        return $this;
207
    }
208
209
    /**
210
     * Check if driver already connected.
211
     *
212
     * @return bool
213
     */
214
    public function isConnected(): bool
215
    {
216
        return !empty($this->pdo);
217
    }
218
219
    /**
220
     * Change PDO instance associated with driver. Returns new copy of driver.
221
     *
222
     * @param PDO $pdo
223
     *
224
     * @return self
225
     */
226
    public function withPDO(PDO $pdo): PDODriver
227
    {
228
        $driver = clone $this;
229
        $driver->pdo = $pdo;
230
231
        return $driver;
232
    }
233
234
    /**
235
     * Get associated PDO connection. Will automatically connect if such connection does not exists.
236
     *
237
     * @return PDO
238
     */
239
    public function getPDO(): PDO
240
    {
241
        if (!$this->isConnected()) {
242
            $this->connect();
243
        }
244
245
        return $this->pdo;
246
    }
247
248
    /**
249
     * Driver specific database/table identifier quotation.
250
     *
251
     * @param string $identifier
252
     *
253
     * @return string
254
     */
255
    public function identifier(string $identifier): string
256
    {
257
        return $identifier == '*' ? '*' : '"' . str_replace('"', '""', $identifier) . '"';
258
    }
259
260
    /**
261
     * Quote value using PDO.
262
     *
263
     * @param mixed $value
264
     * @param int   $type Parameter type.
265
     *
266
     * @return string
267
     */
268
    public function quote($value, int $type = PDO::PARAM_STR): string
269
    {
270
        if ($value instanceof \DateTimeInterface) {
271
            $value = $this->normalizeTimestamp($value);
272
        }
273
274
        return $this->getPDO()->quote($value, $type);
275
    }
276
277
    /**
278
     * Wraps PDO query method with custom representation class.
279
     *
280
     * @param string $statement
281
     * @param array  $parameters
282
     *
283
     * @return PDOResult
284
     */
285
    public function query(string $statement, array $parameters = []): PDOResult
286
    {
287
        //Forcing specific return class
288
        $result = $this->statement($statement, $parameters, PDOResult::class, [$parameters]);
289
290
        /**
291
         * @var PDOResult $result
292
         */
293
        return $result;
294
    }
295
296
    /**
297
     * Create instance of PDOStatement using provided SQL query and set of parameters and execute
298
     * it.
299
     *
300
     * @param string $query
301
     * @param array  $parameters Parameters to be binded into query.
302
     * @param string $class      Class to be used to represent results.
303
     * @param array  $args       Class construction arguments (by default filtered parameters)
304
     *
305
     * @return \PDOStatement
306
     *
307
     * @throws QueryException
308
     */
309
    public function statement(
310
        string $query,
311
        array $parameters = [],
312
        $class = PDOResult::class,
313
        array $args = []
314
    ): \PDOStatement {
315
        try {
316
            //Filtered and normalized parameters
317
            $parameters = $this->flattenParameters($parameters);
318
319
            if ($this->isProfiling()) {
320
                $queryString = QueryInterpolator::interpolate($query, $parameters);
321
                $benchmark = $this->benchmark($this->name, $queryString);
322
            }
323
324
            //PDOStatement instance (prepared)
325
            $pdoStatement = $this->prepare($query, $class, !empty($args) ? $args : [$parameters]);
326
327
            //Mounting all input parameters
328
            $pdoStatement = $this->bindParameters($pdoStatement, $parameters);
329
330
            try {
331
                $pdoStatement->execute();
332
            } finally {
333
                if (!empty($benchmark)) {
334
                    $this->benchmark($benchmark);
335
                }
336
            }
337
338
            //Only exists if profiling on
339
            if (!empty($queryString)) {
340
                $this->logger()->info($queryString, compact('query', 'parameters'));
341
            }
342
343
        } catch (\PDOException $e) {
344
            if (empty($queryString)) {
345
                $queryString = QueryInterpolator::interpolate($query, $parameters);
346
            }
347
348
            //Logging error even when no profiling is enabled
349
            $this->logger()->error($queryString, compact('query', 'parameters'));
350
351
            //Converting exception into query or integrity exception
352
            throw $this->clarifyException($e);
353
        }
354
355
        return $pdoStatement;
356
    }
357
358
    /**
359
     * Get prepared PDO statement.
360
     *
361
     * @param string $statement Query statement.
362
     * @param string $class     Class to represent PDO statement.
363
     * @param array  $args      Class construction arguments (by default paramaters)
364
     *
365
     * @return \PDOStatement
366
     */
367
    public function prepare(
368
        string $statement,
369
        $class = \PDOStatement::class,
370
        array $args = []
371
    ): \PDOStatement {
372
        $pdo = $this->getPDO();
373
374
        $pdo->setAttribute(PDO::ATTR_STATEMENT_CLASS, [$class, $args]);
375
376
        return $pdo->prepare($statement);
377
    }
378
379
    /**
380
     * Get id of last inserted row, this method must be called after insert query. Attention,
381
     * such functionality may not work in some DBMS property (Postgres).
382
     *
383
     * @param string|null $sequence Name of the sequence object from which the ID should be
384
     *                              returned.
385
     *
386
     * @return mixed
387
     */
388
    public function lastInsertID(string $sequence = null)
389
    {
390
        $pdo = $this->getPDO();
391
392
        return $sequence ? (int)$pdo->lastInsertId($sequence) : (int)$pdo->lastInsertId();
393
    }
394
395
    /**
396
     * Prepare set of query builder/user parameters to be send to PDO. Must convert DateTime
397
     * instances into valid database timestamps and resolve values of ParameterInterface.
398
     *
399
     * Every value has to wrapped with parameter interface.
400
     *
401
     * @param array $parameters
402
     *
403
     * @return ParameterInterface[]
404
     *
405
     * @throws DriverException
406
     */
407
    public function flattenParameters(array $parameters): array
408
    {
409
        $flatten = [];
410
        foreach ($parameters as $key => $parameter) {
411
            if (!$parameter instanceof ParameterInterface) {
412
                //Let's wrap value
413
                $parameter = new Parameter($parameter, Parameter::DETECT_TYPE);
414
            }
415
416
            if ($parameter->isArray()) {
417
                if (!is_numeric($key)) {
418
                    throw new DriverException("Array parameters can not be named");
419
                }
420
421
                //Flattening arrays
422
                $nestedParameters = $parameter->flatten();
423
424
                /**
425
                 * @var ParameterInterface $parameter []
426
                 */
427
                foreach ($nestedParameters as &$nestedParameter) {
428
                    if ($nestedParameter->getValue() instanceof \DateTime) {
429
430
                        //Original parameter must not be altered
431
                        $nestedParameter = $nestedParameter->withValue(
432
                            $this->normalizeTimestamp($nestedParameter->getValue())
433
                        );
434
                    }
435
436
                    unset($nestedParameter);
437
                }
438
439
                //Quick and dirty
440
                $flatten = array_merge($flatten, $nestedParameters);
441
442
            } else {
443
                if ($parameter->getValue() instanceof \DateTime) {
444
                    //Original parameter must not be altered
445
                    $parameter = $parameter->withValue(
446
                        $this->normalizeTimestamp($parameter->getValue())
447
                    );
448
                }
449
450
                if (is_numeric($key)) {
451
                    //Numeric keys can be shifted
452
                    $flatten[] = $parameter;
453
                } else {
454
                    $flatten[$key] = $parameter;
455
                }
456
            }
457
        }
458
459
        return $flatten;
460
    }
461
462
    /**
463
     * @return array
464
     */
465
    public function __debugInfo()
466
    {
467
        return [
468
            'connection' => $this->options['connection'],
469
            'connected'  => $this->isConnected(),
470
            'profiling'  => $this->isProfiling(),
471
            'source'     => $this->getSource(),
472
            'options'    => $this->pdoOptions,
473
        ];
474
    }
475
476
    /**
477
     * Create instance of configured PDO class.
478
     *
479
     * @return PDO
480
     */
481
    protected function createPDO(): PDO
482
    {
483
        return new PDO(
484
            $this->options['connection'],
485
            $this->options['username'],
486
            $this->options['password'],
487
            $this->pdoOptions
488
        );
489
    }
490
491
    /**
492
     * Convert PDO exception into query or integrity exception.
493
     *
494
     * @param \PDOException $exception
495
     *
496
     * @return QueryException
497
     */
498
    protected function clarifyException(\PDOException $exception): QueryException
499
    {
500
        //@todo more exceptions to be thrown
501
        return new QueryException($exception);
502
    }
503
504
    /**
505
     * Convert DateTime object into local database representation. Driver will automatically force
506
     * needed timezone.
507
     *
508
     * @param \DateTimeInterface $value
509
     *
510
     * @return string
511
     */
512
    protected function normalizeTimestamp(\DateTimeInterface $value): string
513
    {
514
        $datetime = new \DateTime();
515
        $datetime->setTimestamp($value->getTimestamp());
516
        $datetime->setTimezone($this->getTimezone());
517
518
        return $datetime->format(static::DATETIME);
519
    }
520
521
    /**
522
     * Bind parameters into statement.
523
     *
524
     * @param \PDOStatement        $statement
525
     * @param ParameterInterface[] $parameters Named hash of ParameterInterface.
526
     *
527
     * @return \PDOStatement
528
     */
529
    private function bindParameters(\PDOStatement $statement, array $parameters): \PDOStatement
530
    {
531
        foreach ($parameters as $index => $parameter) {
532
            if (is_numeric($index)) {
533
                //Numeric, @see http://php.net/manual/en/pdostatement.bindparam.php
534
                $statement->bindValue($index + 1, $parameter->getValue(), $parameter->getType());
535
            } else {
536
                //Named
537
                $statement->bindValue($index, $parameter->getValue(), $parameter->getType());
538
            }
539
        }
540
541
        return $statement;
542
    }
543
}
544