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

Driver::__clone()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

Changes 0
Metric Value
cc 1
eloc 2
nc 1
nop 0
dl 0
loc 4
ccs 0
cts 0
cp 0
crap 2
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 70
    protected function __construct(
57
        protected DriverConfig $config,
58
        HandlerInterface $schemaHandler,
59
        protected CompilerInterface $queryCompiler,
60
        BuilderInterface $queryBuilder
61
    ) {
62 70
        $this->schemaHandler = $schemaHandler->withDriver($this);
63 70
        $this->queryBuilder = $queryBuilder->withDriver($this);
64
65 70
        if ($this->config->queryCache && $queryCompiler instanceof CachingCompilerInterface) {
66 66
            $this->queryCompiler = new CompilerCache($queryCompiler);
67
        }
68
69 70
        if ($this->config->readonlySchema) {
70
            $this->schemaHandler = new ReadonlyHandler($this->schemaHandler);
71
        }
72 70
    }
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 18
    public function withName(string $name): static
80
    {
81 18
        $driver = clone $this;
82 18
        $driver->name = $name;
83
84 18
        return $driver;
85
    }
86
87 2
    public function getName(): string
88
    {
89 2
        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 18
    public function __destruct()
101
    {
102 18
        $this->disconnect();
103 18
    }
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 3736
    public function getSchemaHandler(): HandlerInterface
168
    {
169
        // do not allow to carry prepared statements between schema changes
170 3736
        $this->queryCache = [];
171
172 3736
        return $this->schemaHandler;
173
    }
174
175 2592
    public function getQueryCompiler(): CompilerInterface
176
    {
177 2592
        return $this->queryCompiler;
178
    }
179
180 2162
    public function getQueryBuilder(): BuilderInterface
181
    {
182 2162
        return $this->queryBuilder;
183
    }
184
185
    /**
186
     * Force driver connection.
187
     *
188
     * @throws DriverException
189
     */
190 69
    public function connect(): void
191
    {
192 69
        $this->pdo ??= $this->createPDO();
193 69
    }
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 27
    public function disconnect(): void
207
    {
208
        try {
209 27
            $this->queryCache = [];
210 27
            $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 27
        $this->transactionLevel = 0;
217 27
    }
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
    public function getTransactionLevel(): int
277
    {
278
        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 164
     * @link http://en.wikipedia.org/wiki/Database_transaction
286
     * @link http://en.wikipedia.org/wiki/Isolation_(database_systems)
287 164
     *
288
     * @param string|null $isolationLevel
289 164
     */
290 164
    public function beginTransaction(string $isolationLevel = null): bool
291 24
    {
292
        ++$this->transactionLevel;
293
294 164
        if ($this->transactionLevel === 1) {
295
            if ($isolationLevel !== null) {
296
                $this->setIsolationLevel($isolationLevel);
297 164
            }
298
299
            $this->logger?->info('Begin transaction');
300
301
            try {
302
                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 24
                    throw $e;
321
                }
322 24
            }
323
        }
324
325
        $this->createSavepoint($this->transactionLevel);
326
327
        return true;
328
    }
329
330 192
    /**
331
     * Commit the active database transaction.
332
     *
333 192
     * @throws StatementException
334 30
     */
335 30
    public function commitTransaction(): bool
336 30
    {
337 30
        // Check active transaction
338
        if (!$this->getPDO()->inTransaction()) {
339
            $this->logger?->warning(
340
                sprintf(
341 30
                    'Attempt to commit a transaction that has not yet begun. Transaction level: %d',
342 2
                    $this->transactionLevel
343
                )
344
            );
345 28
346 28
            if ($this->transactionLevel === 0) {
347
                return false;
348
            }
349 162
350
            $this->transactionLevel = 0;
351 162
            return true;
352 162
        }
353
354
        --$this->transactionLevel;
355 162
356
        if ($this->transactionLevel === 0) {
357
            $this->logger?->info('Commit transaction');
358
359
            try {
360
                return $this->getPDO()->commit();
361 8
            } catch (Throwable $e) {
362
                throw $this->mapException($e, 'COMMIT TRANSACTION');
363 8
            }
364
        }
365
366
        $this->releaseSavepoint($this->transactionLevel + 1);
367
368
        return true;
369
    }
370
371 40
    /**
372
     * Rollback the active database transaction.
373
     *
374 40
     * @throws StatementException
375 2
     */
376 2
    public function rollbackTransaction(): bool
377 2
    {
378 2
        // Check active transaction
379
        if (!$this->getPDO()->inTransaction()) {
380
            $this->logger?->warning(
381
                sprintf(
382 2
                    'Attempt to rollback a transaction that has not yet begun. Transaction level: %d',
383 2
                    $this->transactionLevel
384
                )
385
            );
386 38
387
            $this->transactionLevel = 0;
388 38
            return false;
389 24
        }
390
391
        --$this->transactionLevel;
392 24
393
        if ($this->transactionLevel === 0) {
394
            $this->logger?->info('Rollback transaction');
395
396
            try {
397
                return $this->getPDO()->rollBack();
398 22
            } catch (Throwable  $e) {
399
                throw $this->mapException($e, 'ROLLBACK TRANSACTION');
400 22
            }
401
        }
402
403
        $this->rollbackSavepoint($this->transactionLevel + 1);
404
405
        return true;
406 3452
    }
407
408 3452
    /**
409
     * @psalm-param non-empty-string $identifier
410
     */
411
    public function identifier(string $identifier): string
412
    {
413
        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 3750
     *
420
     * @psalm-param non-empty-string $query
421 3750
     *
422
     * @throws StatementException
423
     */
424 3750
    protected function statement(string $query, iterable $parameters = [], bool $retry = true): StatementInterface
425 3750
    {
426
        $queryStart = \microtime(true);
427 3750
428 35
        try {
429 35
            $statement = $this->bindParameters($this->prepare($query), $parameters);
430
            $statement->execute();
431
432 35
            return new Statement($statement);
433 35
        } catch (Throwable $e) {
434 35
            $e = $this->mapException($e, Interpolator::interpolate($query, $parameters));
435
436 1
            if (
437
                $retry
438 1
                && $this->transactionLevel === 0
439
                && $e instanceof StatementException\ConnectionException
440
            ) {
441 34
                $this->disconnect();
442
443 3750
                return $this->statement($query, $parameters, false);
444 3716
            }
445 3716
446
            throw $e;
447 3716
        } finally {
448 35
            if ($this->logger !== null) {
449 35
                $queryString = Interpolator::interpolate($query, $parameters);
450
                $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 3716
452
                if (isset($e)) {
453
                    $this->logger->error($queryString, $context);
454
                    $this->logger->alert($e->getMessage());
455
                } else {
456
                    $this->logger->info($queryString, $context);
457
                }
458
            }
459
        }
460 3750
    }
461
462 3750
    /**
463 1360
     * @psalm-param non-empty-string $query
464
     */
465
    protected function prepare(string $query): PDOStatement
466 3750
    {
467 3750
        if ($this->config->queryCache && isset($this->queryCache[$query])) {
468 3750
            return $this->queryCache[$query];
469
        }
470
471 3750
        $statement = $this->getPDO()->prepare($query);
472
        if ($this->config->queryCache) {
473
            $this->queryCache[$query] = $statement;
474
        }
475
476
        return $statement;
477 2824
    }
478
479 2824
    /**
480 2824
     * Bind parameters into statement.
481 1500
     */
482 12
    protected function bindParameters(PDOStatement $statement, iterable $parameters): PDOStatement
483
    {
484 1500
        $index = 0;
485
        foreach ($parameters as $name => $parameter) {
486
            if (is_string($name)) {
487 1500
                $index = $name;
488
            } else {
489 1500
                $index++;
490 282
            }
491 282
492
            $type = PDO::PARAM_STR;
493
494 1500
            if ($parameter instanceof ParameterInterface) {
495 6
                $type = $parameter->getType();
496
                $parameter = $parameter->getValue();
497
            }
498
499 1500
            if ($parameter instanceof DateTimeInterface) {
500
                $parameter = $this->formatDatetime($parameter);
501
            }
502 2824
503
            // numeric, @see http://php.net/manual/en/pdostatement.bindparam.php
504
            $statement->bindValue($index, $parameter, $type);
505
        }
506
507
        return $statement;
508
    }
509
510
    /**
511 40
     * Convert DateTime object into local database representation. Driver will automatically force
512
     * needed timezone.
513
     *
514 40
     * @throws DriverException
515
     */
516
    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
        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 24
     * Set transaction isolation level, this feature may not be supported by specific database
540
     * driver.
541 24
     *
542 24
     * @psalm-param non-empty-string $level
543 24
     */
544
    protected function setIsolationLevel(string $level): void
545
    {
546
        $this->logger?->info("Transaction isolation level '{$level}'");
547
        $this->execute("SET TRANSACTION ISOLATION LEVEL {$level}");
548
    }
549
550
    /**
551
     * Create nested transaction save point.
552 24
     *
553
     * @link http://en.wikipedia.org/wiki/Savepoint
554 24
     *
555
     * @param int $level Savepoint name/id, must not contain spaces and be valid database identifier.
556 24
     */
557 24
    protected function createSavepoint(int $level): void
558
    {
559
        $this->logger?->info("Transaction: new savepoint 'SVP{$level}'");
560
561
        $this->execute('SAVEPOINT ' . $this->identifier("SVP{$level}"));
562
    }
563
564
    /**
565
     * Commit/release savepoint.
566 6
     *
567
     * @link http://en.wikipedia.org/wiki/Savepoint
568 6
     *
569
     * @param int $level Savepoint name/id, must not contain spaces and be valid database identifier.
570 6
     */
571 6
    protected function releaseSavepoint(int $level): void
572
    {
573
        $this->logger?->info("Transaction: release savepoint 'SVP{$level}'");
574
575
        $this->execute('RELEASE SAVEPOINT ' . $this->identifier("SVP{$level}"));
576
    }
577
578
    /**
579
     * Rollback savepoint.
580 16
     *
581
     * @link http://en.wikipedia.org/wiki/Savepoint
582 16
     *
583
     * @param int $level Savepoint name/id, must not contain spaces and be valid database identifier.
584 16
     */
585 16
    protected function rollbackSavepoint(int $level): void
586
    {
587
        $this->logger?->info("Transaction: rollback savepoint 'SVP{$level}'");
588
589
        $this->execute('ROLLBACK TO SAVEPOINT ' . $this->identifier("SVP{$level}"));
590 61
    }
591
592 61
    /**
593
     * Create instance of configured PDO class.
594 61
     */
595
    protected function createPDO(): PDO
596
    {
597
        $connection = $this->config->connection;
598
599
        if (! $connection instanceof PDOConnectionConfig) {
600 61
            throw new \InvalidArgumentException(
601 61
                'Could not establish PDO connection using non-PDO configuration'
602 61
            );
603 61
        }
604 61
605
        return new PDO(
606
            dsn: $connection->getDsn(),
607
            username: $connection->getUsername(),
608
            password: $connection->getPassword(),
609
            options: $connection->getOptions(),
610
        );
611
    }
612
613 3752
    /**
614
     * Get associated PDO connection. Must automatically connect if such connection does not exists.
615 3752
     *
616 27
     * @throws DriverException
617
     */
618
    protected function getPDO(): PDO
619 3752
    {
620
        if ($this->pdo === null) {
621
            $this->connect();
622
        }
623
624
        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 3716
     * Creating a context for logging
629
     *
630 3716
     * @param float $queryStart Query start time
631 3716
     * @param PDOStatement|null $statement Statement
632
     */
633 3716
    protected function defineLoggerContext(float $queryStart, ?PDOStatement $statement): array
634 3716
    {
635
        $context = [
636
            'elapsed' => microtime(true) - $queryStart,
637 3716
        ];
638
        if ($statement !== null) {
639
            $context['rowCount'] = $statement->rowCount();
640 6
        }
641
642 6
        return $context;
643
    }
644
645
    public function __clone(): void
646
    {
647
        $this->schemaHandler = $this->schemaHandler->withDriver($this);
648
        $this->queryBuilder = $this->queryBuilder->withDriver($this);
649
    }
650
}
651