Completed
Branch feature/pre-split (713d19)
by Anton
03:04
created

PDODriver::savepointRelease()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 8
Code Lines 4

Duplication

Lines 8
Ratio 100 %

Importance

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