Passed
Pull Request — 2.x (#44)
by Aleksei
03: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 3
CRAP Score 1

Importance

Changes 0
Metric Value
cc 1
eloc 2
nc 1
nop 0
dl 0
loc 4
ccs 3
cts 3
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 76
    protected function __construct(
57
        protected DriverConfig $config,
58
        HandlerInterface $schemaHandler,
59
        protected CompilerInterface $queryCompiler,
60
        BuilderInterface $queryBuilder
61
    ) {
62 76
        $this->schemaHandler = $schemaHandler->withDriver($this);
63 76
        $this->queryBuilder = $queryBuilder->withDriver($this);
64
65 76
        if ($this->config->queryCache && $queryCompiler instanceof CachingCompilerInterface) {
66 72
            $this->queryCompiler = new CompilerCache($queryCompiler);
67
        }
68
69 76
        if ($this->config->readonlySchema) {
70
            $this->schemaHandler = new ReadonlyHandler($this->schemaHandler);
71
        }
72 76
    }
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 22
    public function __clone()
149
    {
150 22
        $this->schemaHandler = $this->schemaHandler->withDriver($this);
151 22
        $this->queryBuilder = $this->queryBuilder->withDriver($this);
152 22
    }
153
154
    /**
155
     * Get driver source database or file name.
156
     *
157
     * @psalm-return non-empty-string
158
     *
159
     * @throws DriverException
160
     */
161 490
    public function getSource(): string
162
    {
163 490
        $config = $this->config->connection;
164
165 490
        return $config instanceof ProvidesSourceString ? $config->getSourceString() : '*';
166
    }
167
168 1950
    public function getTimezone(): DateTimeZone
169
    {
170 1950
        return new DateTimeZone($this->config->timezone);
171
    }
172
173 3740
    public function getSchemaHandler(): HandlerInterface
174
    {
175
        // do not allow to carry prepared statements between schema changes
176 3740
        $this->queryCache = [];
177
178 3740
        return $this->schemaHandler;
179
    }
180
181 2592
    public function getQueryCompiler(): CompilerInterface
182
    {
183 2592
        return $this->queryCompiler;
184
    }
185
186 2166
    public function getQueryBuilder(): BuilderInterface
187
    {
188 2166
        return $this->queryBuilder;
189
    }
190
191
    /**
192
     * Force driver connection.
193
     *
194
     * @throws DriverException
195
     */
196 70
    public function connect(): void
197
    {
198 70
        $this->pdo ??= $this->createPDO();
199 70
    }
200
201
    /**
202
     * Check if driver already connected.
203
     */
204 24
    public function isConnected(): bool
205
    {
206 24
        return $this->pdo !== null;
207
    }
208
209
    /**
210
     * Disconnect driver.
211
     */
212 10
    public function disconnect(): void
213
    {
214
        try {
215 10
            $this->queryCache = [];
216 10
            $this->pdo = null;
217
        } 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...
218
            // disconnect error
219
            $this->logger?->error($e->getMessage());
220
        }
221
222 10
        $this->transactionLevel = 0;
223 10
    }
224
225
    /**
226
     * @psalm-return non-empty-string
227
     */
228 1030
    public function quote($value, int $type = PDO::PARAM_STR): string
229
    {
230 1030
        if ($value instanceof DateTimeInterface) {
231 32
            $value = $this->formatDatetime($value);
232
        }
233
234 1030
        return $this->getPDO()->quote($value, $type);
235
    }
236
237
    /**
238
     * Execute query and return query statement.
239
     *
240
     * @psalm-param non-empty-string $statement
241
     *
242
     * @throws StatementException
243
     */
244 3750
    public function query(string $statement, array $parameters = []): StatementInterface
245
    {
246 3750
        return $this->statement($statement, $parameters);
247
    }
248
249
    /**
250
     * Execute query and return number of affected rows.
251
     *
252
     * @psalm-param non-empty-string $query
253
     *
254
     * @throws StatementException
255
     * @throws ReadonlyConnectionException
256
     */
257 1954
    public function execute(string $query, array $parameters = []): int
258
    {
259 1954
        if ($this->isReadonly()) {
260 80
            throw ReadonlyConnectionException::onWriteStatementExecution();
261
        }
262
263 1954
        return $this->statement($query, $parameters)->rowCount();
264
    }
265
266
    /**
267
     * Get id of last inserted row, this method must be called after insert query. Attention,
268
     * such functionality may not work in some DBMS property (Postgres).
269
     *
270
     * @param string|null $sequence Name of the sequence object from which the ID should be returned.
271
     *
272
     * @return mixed
273
     */
274 274
    public function lastInsertID(string $sequence = null)
275
    {
276 274
        $result = $this->getPDO()->lastInsertId();
277 274
        $this->logger?->debug("Insert ID: {$result}");
278
279 274
        return $result;
280
    }
281
282 6
    public function getTransactionLevel(): int
283
    {
284 6
        return $this->transactionLevel;
285
    }
286
287
    /**
288
     * Start SQL transaction with specified isolation level (not all DBMS support it). Nested
289
     * transactions are processed using savepoints.
290
     *
291
     * @link http://en.wikipedia.org/wiki/Database_transaction
292
     * @link http://en.wikipedia.org/wiki/Isolation_(database_systems)
293
     *
294
     * @param string|null $isolationLevel
295
     */
296 164
    public function beginTransaction(string $isolationLevel = null): bool
297
    {
298 164
        ++$this->transactionLevel;
299
300 164
        if ($this->transactionLevel === 1) {
301 164
            if ($isolationLevel !== null) {
302 24
                $this->setIsolationLevel($isolationLevel);
303
            }
304
305 164
            $this->logger?->info('Begin transaction');
306
307
            try {
308 164
                return $this->getPDO()->beginTransaction();
309
            } catch (Throwable  $e) {
310
                $e = $this->mapException($e, 'BEGIN TRANSACTION');
311
312
                if (
313
                    $e instanceof StatementException\ConnectionException
314
                    && $this->config->reconnect
315
                ) {
316
                    $this->disconnect();
317
318
                    try {
319
                        return $this->getPDO()->beginTransaction();
320
                    } catch (Throwable $e) {
321
                        $this->transactionLevel = 0;
322
                        throw $this->mapException($e, 'BEGIN TRANSACTION');
323
                    }
324
                } else {
325
                    $this->transactionLevel = 0;
326
                    throw $e;
327
                }
328
            }
329
        }
330
331 24
        $this->createSavepoint($this->transactionLevel);
332
333 24
        return true;
334
    }
335
336
    /**
337
     * Commit the active database transaction.
338
     *
339
     * @throws StatementException
340
     */
341 192
    public function commitTransaction(): bool
342
    {
343
        // Check active transaction
344 192
        if (!$this->getPDO()->inTransaction()) {
345 30
            $this->logger?->warning(
346 30
                sprintf(
347 30
                    'Attempt to commit a transaction that has not yet begun. Transaction level: %d',
348 30
                    $this->transactionLevel
349
                )
350
            );
351
352 30
            if ($this->transactionLevel === 0) {
353 2
                return false;
354
            }
355
356 28
            $this->transactionLevel = 0;
357 28
            return true;
358
        }
359
360 162
        --$this->transactionLevel;
361
362 162
        if ($this->transactionLevel === 0) {
363 162
            $this->logger?->info('Commit transaction');
364
365
            try {
366 162
                return $this->getPDO()->commit();
367
            } catch (Throwable $e) {
368
                throw $this->mapException($e, 'COMMIT TRANSACTION');
369
            }
370
        }
371
372 8
        $this->releaseSavepoint($this->transactionLevel + 1);
373
374 8
        return true;
375
    }
376
377
    /**
378
     * Rollback the active database transaction.
379
     *
380
     * @throws StatementException
381
     */
382 40
    public function rollbackTransaction(): bool
383
    {
384
        // Check active transaction
385 40
        if (!$this->getPDO()->inTransaction()) {
386 2
            $this->logger?->warning(
387 2
                sprintf(
388 2
                    'Attempt to rollback a transaction that has not yet begun. Transaction level: %d',
389 2
                    $this->transactionLevel
390
                )
391
            );
392
393 2
            $this->transactionLevel = 0;
394 2
            return false;
395
        }
396
397 38
        --$this->transactionLevel;
398
399 38
        if ($this->transactionLevel === 0) {
400 24
            $this->logger?->info('Rollback transaction');
401
402
            try {
403 24
                return $this->getPDO()->rollBack();
404
            } catch (Throwable  $e) {
405
                throw $this->mapException($e, 'ROLLBACK TRANSACTION');
406
            }
407
        }
408
409 22
        $this->rollbackSavepoint($this->transactionLevel + 1);
410
411 22
        return true;
412
    }
413
414
    /**
415
     * @psalm-param non-empty-string $identifier
416
     */
417 3452
    public function identifier(string $identifier): string
418
    {
419 3452
        return $this->queryCompiler->quoteIdentifier($identifier);
420
    }
421
422
    /**
423
     * Create instance of PDOStatement using provided SQL query and set of parameters and execute
424
     * it. Will attempt singular reconnect.
425
     *
426
     * @psalm-param non-empty-string $query
427
     *
428
     * @throws StatementException
429
     */
430 3750
    protected function statement(string $query, iterable $parameters = [], bool $retry = true): StatementInterface
431
    {
432 3750
        $queryStart = \microtime(true);
433
434
        try {
435 3750
            $statement = $this->bindParameters($this->prepare($query), $parameters);
436 3750
            $statement->execute();
437
438 3750
            return new Statement($statement);
439 36
        } catch (Throwable $e) {
440 36
            $e = $this->mapException($e, Interpolator::interpolate($query, $parameters));
441
442
            if (
443 36
                $retry
444 36
                && $this->transactionLevel === 0
445 36
                && $e instanceof StatementException\ConnectionException
446
            ) {
447 2
                $this->disconnect();
448
449 2
                return $this->statement($query, $parameters, false);
450
            }
451
452 34
            throw $e;
453
        } finally {
454 3750
            if ($this->logger !== null) {
455 3716
                $queryString = Interpolator::interpolate($query, $parameters);
456 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

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