Passed
Pull Request — 2.x (#44)
by butschster
03:27
created

Driver::identifier()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

Changes 0
Metric Value
cc 1
eloc 1
nc 1
nop 1
dl 0
loc 3
ccs 2
cts 2
cp 1
crap 1
rs 10
c 0
b 0
f 0
1
<?php
2
3
/**
4
 * This file is part of Cycle ORM package.
5
 *
6
 * For the full copyright and license information, please view the LICENSE
7
 * file that was distributed with this source code.
8
 */
9
10
declare(strict_types=1);
11
12
namespace Cycle\Database\Driver;
13
14
use Cycle\Database\Config\DriverConfig;
15
use Cycle\Database\Config\PDOConnectionConfig;
16
use Cycle\Database\Config\ProvidesSourceString;
17
use Cycle\Database\Exception\DriverException;
18
use Cycle\Database\Exception\ReadonlyConnectionException;
19
use Cycle\Database\Exception\StatementException;
20
use Cycle\Database\Injection\ParameterInterface;
21
use Cycle\Database\NamedInterface;
22
use Cycle\Database\Query\BuilderInterface;
23
use Cycle\Database\Query\Interpolator;
24
use Cycle\Database\StatementInterface;
25
use DateTimeImmutable;
26
use DateTimeInterface;
27
use DateTimeZone;
28
use PDO;
29
use PDOStatement;
30
use Psr\Log\LoggerAwareInterface;
31
use Psr\Log\LoggerAwareTrait;
32
use Throwable;
33
34
/**
35
 * Provides low level abstraction at top of
36
 */
37
abstract class Driver implements DriverInterface, NamedInterface, LoggerAwareInterface
38
{
39
    use LoggerAwareTrait;
40
41
    /**
42
     * DateTime format to be used to perform automatic conversion of DateTime objects.
43
     *
44
     * @var non-empty-string (Typehint required for overriding behaviour)
45
     */
46
    protected const DATETIME = 'Y-m-d H:i:s';
47
    protected ?\PDO $pdo = null;
48
    protected int $transactionLevel = 0;
49
    protected HandlerInterface $schemaHandler;
50
    protected BuilderInterface $queryBuilder;
51
52
    /** @var PDOStatement[] */
53
    protected array $queryCache = [];
54
    private ?string $name = null;
55
56 74
    protected function __construct(
57
        protected DriverConfig $config,
58
        HandlerInterface $schemaHandler,
59
        protected CompilerInterface $queryCompiler,
60
        BuilderInterface $queryBuilder
61
    ) {
62 74
        $this->schemaHandler = $schemaHandler->withDriver($this);
63 74
        $this->queryBuilder = $queryBuilder->withDriver($this);
64
65 74
        if ($this->config->queryCache && $queryCompiler instanceof CachingCompilerInterface) {
66 70
            $this->queryCompiler = new CompilerCache($queryCompiler);
67
        }
68
69 74
        if ($this->config->readonlySchema) {
70
            $this->schemaHandler = new ReadonlyHandler($this->schemaHandler);
71
        }
72 74
    }
73
74
    /**
75
     * @param non-empty-string $name
0 ignored issues
show
Documentation Bug introduced by
The doc comment non-empty-string at position 0 could not be parsed: Unknown type name 'non-empty-string' at position 0 in non-empty-string.
Loading history...
76
     *
77
     * @internal
78
     */
79 20
    public function withName(string $name): static
80
    {
81 20
        $driver = clone $this;
82 20
        $driver->name = $name;
83
84 20
        return $driver;
85
    }
86
87 6
    public function getName(): string
88
    {
89 6
        return $this->name ?? throw new \RuntimeException('Driver name is not defined.');
90
    }
91
92 1954
    public function isReadonly(): bool
93
    {
94 1954
        return $this->config->readonly;
95
    }
96
97
    /**
98
     * Disconnect and destruct.
99
     */
100
    public function __destruct()
101
    {
102
        $this->disconnect();
103
    }
104
105 8
    public function __debugInfo(): array
106
    {
107
        return [
108 8
            'connection' => $this->config->connection,
109 8
            'source' => $this->getSource(),
110 8
            'connected' => $this->isConnected(),
111 8
            'options' => $this->config,
112
        ];
113
    }
114
115
    /**
116
     * Compatibility with deprecated methods.
117
     *
118
     * @psalm-param non-empty-string $name
119
     *
120
     * @deprecated this method will be removed in a future releases.
121
     */
122 12
    public function __call(string $name, array $arguments): mixed
123
    {
124 12
        return match ($name) {
125
            'isProfiling' => true,
126
            'setProfiling' => null,
127 6
            'getSchema' => $this->getSchemaHandler()->getSchema(
128 6
                $arguments[0],
129 6
                $arguments[1] ?? null
130
            ),
131
            'tableNames' => $this->getSchemaHandler()->getTableNames(),
132
            'hasTable' => $this->getSchemaHandler()->hasTable($arguments[0]),
133
            'identifier' => $this->getQueryCompiler()->quoteIdentifier($arguments[0]),
134
            'eraseData' => $this->getSchemaHandler()->eraseTable(
0 ignored issues
show
Bug introduced by
Are you sure the usage of $this->getSchemaHandler(...tSchema($arguments[0])) targeting Cycle\Database\Driver\Ha...Interface::eraseTable() seems to always return null.

This check looks for function or method calls that always return null and whose return value is used.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
if ($a->getObject()) {

The method getObject() can return nothing but null, so it makes no sense to use the return value.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
135
                $this->getSchemaHandler()->getSchema($arguments[0])
136
            ),
137
            'insertQuery',
138
            'selectQuery',
139
            'updateQuery',
140 4
            'deleteQuery' => call_user_func_array(
141 4
                [$this->queryBuilder, $name],
142
                $arguments
143
            ),
144 12
            default => throw new DriverException("Undefined driver method `{$name}`")
145
        };
146
    }
147
148
    /**
149
     * Get driver source database or file name.
150
     *
151
     * @psalm-return non-empty-string
152
     *
153
     * @throws DriverException
154
     */
155 490
    public function getSource(): string
156
    {
157 490
        $config = $this->config->connection;
158
159 490
        return $config instanceof ProvidesSourceString ? $config->getSourceString() : '*';
160
    }
161
162 1950
    public function getTimezone(): DateTimeZone
163
    {
164 1950
        return new DateTimeZone($this->config->timezone);
165
    }
166
167 3738
    public function getSchemaHandler(): HandlerInterface
168
    {
169
        // do not allow to carry prepared statements between schema changes
170 3738
        $this->queryCache = [];
171
172 3738
        return $this->schemaHandler;
173
    }
174
175 2592
    public function getQueryCompiler(): CompilerInterface
176
    {
177 2592
        return $this->queryCompiler;
178
    }
179
180 2164
    public function getQueryBuilder(): BuilderInterface
181
    {
182 2164
        return $this->queryBuilder;
183
    }
184
185
    /**
186
     * Force driver connection.
187
     *
188
     * @throws DriverException
189
     */
190 70
    public function connect(): void
191
    {
192 70
        $this->pdo ??= $this->createPDO();
193 70
    }
194
195
    /**
196
     * Check if driver already connected.
197
     */
198 24
    public function isConnected(): bool
199
    {
200 24
        return $this->pdo !== null;
201
    }
202
203
    /**
204
     * Disconnect driver.
205
     */
206 10
    public function disconnect(): void
207
    {
208
        try {
209 10
            $this->queryCache = [];
210 10
            $this->pdo = null;
211
        } catch (Throwable $e) {
0 ignored issues
show
Unused Code introduced by
catch (\Throwable $e) is not reachable.

This check looks for unreachable code. It uses sophisticated control flow analysis techniques to find statements which will never be executed.

Unreachable code is most often the result of return, die or exit statements that have been added for debug purposes.

function fx() {
    try {
        doSomething();
        return true;
    }
    catch (\Exception $e) {
        return false;
    }

    return false;
}

In the above example, the last return false will never be executed, because a return statement has already been met in every possible execution path.

Loading history...
212
            // disconnect error
213
            $this->logger?->error($e->getMessage());
214
        }
215
216 10
        $this->transactionLevel = 0;
217 10
    }
218
219
    /**
220
     * @psalm-return non-empty-string
221
     */
222 1030
    public function quote($value, int $type = PDO::PARAM_STR): string
223
    {
224 1030
        if ($value instanceof DateTimeInterface) {
225 32
            $value = $this->formatDatetime($value);
226
        }
227
228 1030
        return $this->getPDO()->quote($value, $type);
229
    }
230
231
    /**
232
     * Execute query and return query statement.
233
     *
234
     * @psalm-param non-empty-string $statement
235
     *
236
     * @throws StatementException
237
     */
238 3750
    public function query(string $statement, array $parameters = []): StatementInterface
239
    {
240 3750
        return $this->statement($statement, $parameters);
241
    }
242
243
    /**
244
     * Execute query and return number of affected rows.
245
     *
246
     * @psalm-param non-empty-string $query
247
     *
248
     * @throws StatementException
249
     * @throws ReadonlyConnectionException
250
     */
251 1954
    public function execute(string $query, array $parameters = []): int
252
    {
253 1954
        if ($this->isReadonly()) {
254 80
            throw ReadonlyConnectionException::onWriteStatementExecution();
255
        }
256
257 1954
        return $this->statement($query, $parameters)->rowCount();
258
    }
259
260
    /**
261
     * Get id of last inserted row, this method must be called after insert query. Attention,
262
     * such functionality may not work in some DBMS property (Postgres).
263
     *
264
     * @param string|null $sequence Name of the sequence object from which the ID should be returned.
265
     *
266
     * @return mixed
267
     */
268 274
    public function lastInsertID(string $sequence = null)
269
    {
270 274
        $result = $this->getPDO()->lastInsertId();
271 274
        $this->logger?->debug("Insert ID: {$result}");
272
273 274
        return $result;
274
    }
275
276 6
    public function getTransactionLevel(): int
277
    {
278 6
        return $this->transactionLevel;
279
    }
280
281
    /**
282
     * Start SQL transaction with specified isolation level (not all DBMS support it). Nested
283
     * transactions are processed using savepoints.
284
     *
285
     * @link http://en.wikipedia.org/wiki/Database_transaction
286
     * @link http://en.wikipedia.org/wiki/Isolation_(database_systems)
287
     *
288
     * @param string|null $isolationLevel
289
     */
290 164
    public function beginTransaction(string $isolationLevel = null): bool
291
    {
292 164
        ++$this->transactionLevel;
293
294 164
        if ($this->transactionLevel === 1) {
295 164
            if ($isolationLevel !== null) {
296 24
                $this->setIsolationLevel($isolationLevel);
297
            }
298
299 164
            $this->logger?->info('Begin transaction');
300
301
            try {
302 164
                return $this->getPDO()->beginTransaction();
303
            } catch (Throwable  $e) {
304
                $e = $this->mapException($e, 'BEGIN TRANSACTION');
305
306
                if (
307
                    $e instanceof StatementException\ConnectionException
308
                    && $this->config->reconnect
309
                ) {
310
                    $this->disconnect();
311
312
                    try {
313
                        return $this->getPDO()->beginTransaction();
314
                    } catch (Throwable $e) {
315
                        $this->transactionLevel = 0;
316
                        throw $this->mapException($e, 'BEGIN TRANSACTION');
317
                    }
318
                } else {
319
                    $this->transactionLevel = 0;
320
                    throw $e;
321
                }
322
            }
323
        }
324
325 24
        $this->createSavepoint($this->transactionLevel);
326
327 24
        return true;
328
    }
329
330
    /**
331
     * Commit the active database transaction.
332
     *
333
     * @throws StatementException
334
     */
335 192
    public function commitTransaction(): bool
336
    {
337
        // Check active transaction
338 192
        if (!$this->getPDO()->inTransaction()) {
339 30
            $this->logger?->warning(
340 30
                sprintf(
341 30
                    'Attempt to commit a transaction that has not yet begun. Transaction level: %d',
342 30
                    $this->transactionLevel
343
                )
344
            );
345
346 30
            if ($this->transactionLevel === 0) {
347 2
                return false;
348
            }
349
350 28
            $this->transactionLevel = 0;
351 28
            return true;
352
        }
353
354 162
        --$this->transactionLevel;
355
356 162
        if ($this->transactionLevel === 0) {
357 162
            $this->logger?->info('Commit transaction');
358
359
            try {
360 162
                return $this->getPDO()->commit();
361
            } catch (Throwable $e) {
362
                throw $this->mapException($e, 'COMMIT TRANSACTION');
363
            }
364
        }
365
366 8
        $this->releaseSavepoint($this->transactionLevel + 1);
367
368 8
        return true;
369
    }
370
371
    /**
372
     * Rollback the active database transaction.
373
     *
374
     * @throws StatementException
375
     */
376 40
    public function rollbackTransaction(): bool
377
    {
378
        // Check active transaction
379 40
        if (!$this->getPDO()->inTransaction()) {
380 2
            $this->logger?->warning(
381 2
                sprintf(
382 2
                    'Attempt to rollback a transaction that has not yet begun. Transaction level: %d',
383 2
                    $this->transactionLevel
384
                )
385
            );
386
387 2
            $this->transactionLevel = 0;
388 2
            return false;
389
        }
390
391 38
        --$this->transactionLevel;
392
393 38
        if ($this->transactionLevel === 0) {
394 24
            $this->logger?->info('Rollback transaction');
395
396
            try {
397 24
                return $this->getPDO()->rollBack();
398
            } catch (Throwable  $e) {
399
                throw $this->mapException($e, 'ROLLBACK TRANSACTION');
400
            }
401
        }
402
403 22
        $this->rollbackSavepoint($this->transactionLevel + 1);
404
405 22
        return true;
406
    }
407
408
    /**
409
     * @psalm-param non-empty-string $identifier
410
     */
411 3452
    public function identifier(string $identifier): string
412
    {
413 3452
        return $this->queryCompiler->quoteIdentifier($identifier);
414
    }
415
416
    /**
417
     * Create instance of PDOStatement using provided SQL query and set of parameters and execute
418
     * it. Will attempt singular reconnect.
419
     *
420
     * @psalm-param non-empty-string $query
421
     *
422
     * @throws StatementException
423
     */
424 3750
    protected function statement(string $query, iterable $parameters = [], bool $retry = true): StatementInterface
425
    {
426 3750
        $queryStart = \microtime(true);
427
428
        try {
429 3750
            $statement = $this->bindParameters($this->prepare($query), $parameters);
430 3750
            $statement->execute();
431
432 3750
            return new Statement($statement);
433 36
        } catch (Throwable $e) {
434 36
            $e = $this->mapException($e, Interpolator::interpolate($query, $parameters));
435
436
            if (
437 36
                $retry
438 36
                && $this->transactionLevel === 0
439 36
                && $e instanceof StatementException\ConnectionException
440
            ) {
441 2
                $this->disconnect();
442
443 2
                return $this->statement($query, $parameters, false);
444
            }
445
446 34
            throw $e;
447
        } finally {
448 3750
            if ($this->logger !== null) {
449 3716
                $queryString = Interpolator::interpolate($query, $parameters);
450 3716
                $context = $this->defineLoggerContext($queryStart, $statement ?? null);
0 ignored issues
show
Bug introduced by
It seems like $queryStart can also be of type string; however, parameter $queryStart of Cycle\Database\Driver\Dr...::defineLoggerContext() does only seem to accept double, maybe add an additional type check? ( Ignorable by Annotation )

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

450
                $context = $this->defineLoggerContext(/** @scrutinizer ignore-type */ $queryStart, $statement ?? null);
Loading history...
451
452 3716
                if (isset($e)) {
453 36
                    $this->logger->error($queryString, $context);
454 36
                    $this->logger->alert($e->getMessage());
455
                } else {
456 3716
                    $this->logger->info($queryString, $context);
457
                }
458
            }
459
        }
460
    }
461
462
    /**
463
     * @psalm-param non-empty-string $query
464
     */
465 3750
    protected function prepare(string $query): PDOStatement
466
    {
467 3750
        if ($this->config->queryCache && isset($this->queryCache[$query])) {
468 1360
            return $this->queryCache[$query];
469
        }
470
471 3750
        $statement = $this->getPDO()->prepare($query);
472 3750
        if ($this->config->queryCache) {
473 3750
            $this->queryCache[$query] = $statement;
474
        }
475
476 3750
        return $statement;
477
    }
478
479
    /**
480
     * Bind parameters into statement.
481
     */
482 2824
    protected function bindParameters(PDOStatement $statement, iterable $parameters): PDOStatement
483
    {
484 2824
        $index = 0;
485 2824
        foreach ($parameters as $name => $parameter) {
486 1500
            if (is_string($name)) {
487 12
                $index = $name;
488
            } else {
489 1500
                $index++;
490
            }
491
492 1500
            $type = PDO::PARAM_STR;
493
494 1500
            if ($parameter instanceof ParameterInterface) {
495 282
                $type = $parameter->getType();
496 282
                $parameter = $parameter->getValue();
497
            }
498
499 1500
            if ($parameter instanceof DateTimeInterface) {
500 6
                $parameter = $this->formatDatetime($parameter);
501
            }
502
503
            // numeric, @see http://php.net/manual/en/pdostatement.bindparam.php
504 1500
            $statement->bindValue($index, $parameter, $type);
505
        }
506
507 2824
        return $statement;
508
    }
509
510
    /**
511
     * Convert DateTime object into local database representation. Driver will automatically force
512
     * needed timezone.
513
     *
514
     * @throws DriverException
515
     */
516 40
    protected function formatDatetime(DateTimeInterface $value): string
517
    {
518
        try {
519 40
            $datetime = new DateTimeImmutable('now', $this->getTimezone());
520
        } catch (Throwable $e) {
521
            throw new DriverException($e->getMessage(), (int)$e->getCode(), $e);
522
        }
523
524 40
        return $datetime->setTimestamp($value->getTimestamp())->format(static::DATETIME);
525
    }
526
527
    /**
528
     * Convert PDO exception into query or integrity exception.
529
     *
530
     * @param Throwable $exception
531
     * @psalm-param non-empty-string $query
532
     */
533
    abstract protected function mapException(
534
        Throwable $exception,
535
        string $query
536
    ): StatementException;
537
538
    /**
539
     * Set transaction isolation level, this feature may not be supported by specific database
540
     * driver.
541
     *
542
     * @psalm-param non-empty-string $level
543
     */
544 24
    protected function setIsolationLevel(string $level): void
545
    {
546 24
        $this->logger?->info("Transaction isolation level '{$level}'");
547 24
        $this->execute("SET TRANSACTION ISOLATION LEVEL {$level}");
548 24
    }
549
550
    /**
551
     * Create nested transaction save point.
552
     *
553
     * @link http://en.wikipedia.org/wiki/Savepoint
554
     *
555
     * @param int $level Savepoint name/id, must not contain spaces and be valid database identifier.
556
     */
557 24
    protected function createSavepoint(int $level): void
558
    {
559 24
        $this->logger?->info("Transaction: new savepoint 'SVP{$level}'");
560
561 24
        $this->execute('SAVEPOINT ' . $this->identifier("SVP{$level}"));
562 24
    }
563
564
    /**
565
     * Commit/release savepoint.
566
     *
567
     * @link http://en.wikipedia.org/wiki/Savepoint
568
     *
569
     * @param int $level Savepoint name/id, must not contain spaces and be valid database identifier.
570
     */
571 6
    protected function releaseSavepoint(int $level): void
572
    {
573 6
        $this->logger?->info("Transaction: release savepoint 'SVP{$level}'");
574
575 6
        $this->execute('RELEASE SAVEPOINT ' . $this->identifier("SVP{$level}"));
576 6
    }
577
578
    /**
579
     * Rollback savepoint.
580
     *
581
     * @link http://en.wikipedia.org/wiki/Savepoint
582
     *
583
     * @param int $level Savepoint name/id, must not contain spaces and be valid database identifier.
584
     */
585 16
    protected function rollbackSavepoint(int $level): void
586
    {
587 16
        $this->logger?->info("Transaction: rollback savepoint 'SVP{$level}'");
588
589 16
        $this->execute('ROLLBACK TO SAVEPOINT ' . $this->identifier("SVP{$level}"));
590 16
    }
591
592
    /**
593
     * Create instance of configured PDO class.
594
     */
595 62
    protected function createPDO(): PDO
596
    {
597 62
        $connection = $this->config->connection;
598
599 62
        if (! $connection instanceof PDOConnectionConfig) {
600
            throw new \InvalidArgumentException(
601
                'Could not establish PDO connection using non-PDO configuration'
602
            );
603
        }
604
605 62
        return new PDO(
606 62
            dsn: $connection->getDsn(),
607 62
            username: $connection->getUsername(),
608 62
            password: $connection->getPassword(),
609 62
            options: $connection->getOptions(),
610
        );
611
    }
612
613
    /**
614
     * Get associated PDO connection. Must automatically connect if such connection does not exists.
615
     *
616
     * @throws DriverException
617
     */
618 3752
    protected function getPDO(): PDO
619
    {
620 3752
        if ($this->pdo === null) {
621 28
            $this->connect();
622
        }
623
624 3752
        return $this->pdo;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->pdo could return the type null which is incompatible with the type-hinted return PDO. Consider adding an additional type-check to rule them out.
Loading history...
625
    }
626
627
    /**
628
     * Creating a context for logging
629
     *
630
     * @param float $queryStart Query start time
631
     * @param PDOStatement|null $statement Statement
632
     */
633 3716
    protected function defineLoggerContext(float $queryStart, ?PDOStatement $statement): array
634
    {
635 3716
        $context = [
636 3716
            'elapsed' => microtime(true) - $queryStart,
637
        ];
638 3716
        if ($statement !== null) {
639 3716
            $context['rowCount'] = $statement->rowCount();
640
        }
641
642 3716
        return $context;
643
    }
644
645 20
    public function __clone()
646
    {
647 20
        $this->schemaHandler = $this->schemaHandler->withDriver($this);
648 20
        $this->queryBuilder = $this->queryBuilder->withDriver($this);
649 20
    }
650
}
651