Passed
Pull Request — 2.x (#137)
by Maxim
19:59
created

Driver::connect()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

Changes 0
Metric Value
cc 1
eloc 1
nc 1
nop 0
dl 0
loc 3
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 BackedEnum;
0 ignored issues
show
Bug introduced by
The type BackedEnum was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
15
use Cycle\Database\Config\DriverConfig;
16
use Cycle\Database\Config\PDOConnectionConfig;
17
use Cycle\Database\Config\ProvidesSourceString;
18
use Cycle\Database\Exception\DriverException;
19
use Cycle\Database\Exception\ReadonlyConnectionException;
20
use Cycle\Database\Exception\StatementException;
21
use Cycle\Database\Injection\ParameterInterface;
22
use Cycle\Database\NamedInterface;
23
use Cycle\Database\Query\BuilderInterface;
24
use Cycle\Database\Query\Interpolator;
25
use Cycle\Database\StatementInterface;
26
use DateTimeImmutable;
27
use DateTimeInterface;
28
use DateTimeZone;
29
use PDO;
30
use PDOStatement;
31
use Psr\Log\LoggerAwareInterface;
32
use Psr\Log\LoggerAwareTrait;
33
use Throwable;
34
35
/**
36
 * Provides low level abstraction at top of
37
 */
38
abstract class Driver implements DriverInterface, NamedInterface, LoggerAwareInterface
39
{
40
    use LoggerAwareTrait;
41
42
    /**
43
     * DateTime format to be used to perform automatic conversion of DateTime objects.
44
     *
45
     * @var non-empty-string (Typehint required for overriding behaviour)
46
     */
47
    protected const DATETIME = 'Y-m-d H:i:s';
48
    protected const DATETIME_MICROSECONDS = 'Y-m-d H:i:s.u';
49
    protected ?\PDO $pdo = null;
50
    protected int $transactionLevel = 0;
51
    protected HandlerInterface $schemaHandler;
52
    protected BuilderInterface $queryBuilder;
53
54
    /** @var PDOStatement[]|PDOStatementInterface[] */
55
    protected array $queryCache = [];
56 76
    private ?string $name = null;
57
    private bool $useCache = true;
58
59
    protected function __construct(
60
        protected DriverConfig $config,
61
        HandlerInterface $schemaHandler,
62 76
        protected CompilerInterface $queryCompiler,
63 76
        BuilderInterface $queryBuilder
64
    ) {
65 76
        $this->useCache = $this->config->queryCache;
66 72
67
        $this->schemaHandler = $schemaHandler->withDriver($this);
68
        $this->queryBuilder = $queryBuilder->withDriver($this);
69 76
70
        if ($this->useCache && $queryCompiler instanceof CachingCompilerInterface) {
71
            $this->queryCompiler = new CompilerCache($queryCompiler);
72 76
        }
73
74
        if ($this->config->readonlySchema) {
75
            $this->schemaHandler = new ReadonlyHandler($this->schemaHandler);
76
        }
77
    }
78
79 20
    /**
80
     * @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...
81 20
     *
82 20
     * @internal
83
     */
84 20
    public function withName(string $name): static
85
    {
86
        $driver = clone $this;
87 6
        $driver->name = $name;
88
89 6
        return $driver;
90
    }
91
92 1954
    public function getName(): string
93
    {
94 1954
        return $this->name ?? throw new \RuntimeException('Driver name is not defined.');
95
    }
96
97
    public function isReadonly(): bool
98
    {
99
        return $this->config->readonly;
100
    }
101
102
    public function withoutCache(): static
103
    {
104
        $driver = clone $this;
105 8
        $driver->useCache = false;
106
107
        return $driver;
108 8
    }
109 8
110 8
    /**
111 8
     * Disconnect and destruct.
112
     */
113
    public function __destruct()
114
    {
115
        $this->disconnect();
116
    }
117
118
    public function __debugInfo(): array
119
    {
120
        return [
121
            'connection' => $this->config->connection,
122 12
            'source' => $this->getSource(),
123
            'connected' => $this->isConnected(),
124 12
            'options' => $this->config,
125
        ];
126
    }
127 6
128 6
    /**
129 6
     * Compatibility with deprecated methods.
130
     *
131
     * @psalm-param non-empty-string $name
132
     *
133
     * @deprecated this method will be removed in a future releases.
134
     */
135
    public function __call(string $name, array $arguments): mixed
136
    {
137
        return match ($name) {
138
            'isProfiling' => true,
139
            'setProfiling' => null,
140 4
            'getSchema' => $this->getSchemaHandler()->getSchema(
141 4
                $arguments[0],
142
                $arguments[1] ?? null
143
            ),
144 12
            'tableNames' => $this->getSchemaHandler()->getTableNames(),
145
            'hasTable' => $this->getSchemaHandler()->hasTable($arguments[0]),
146
            'identifier' => $this->getQueryCompiler()->quoteIdentifier($arguments[0]),
147
            '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...
148 22
                $this->getSchemaHandler()->getSchema($arguments[0])
149
            ),
150 22
            'insertQuery',
151 22
            'selectQuery',
152 22
            'updateQuery',
153
            'deleteQuery' => call_user_func_array(
154
                [$this->queryBuilder, $name],
155
                $arguments
156
            ),
157
            default => throw new DriverException("Undefined driver method `{$name}`")
158
        };
159
    }
160
161 490
    public function __clone()
162
    {
163 490
        $this->schemaHandler = $this->schemaHandler->withDriver($this);
164
        $this->queryBuilder = $this->queryBuilder->withDriver($this);
165 490
    }
166
167
    /**
168 1950
     * Get driver source database or file name.
169
     *
170 1950
     * @psalm-return non-empty-string
171
     *
172
     * @throws DriverException
173 3748
     */
174
    public function getSource(): string
175
    {
176 3748
        $config = $this->config->connection;
177
178 3748
        return $config instanceof ProvidesSourceString ? $config->getSourceString() : '*';
179
    }
180
181 2600
    public function getTimezone(): DateTimeZone
182
    {
183 2600
        return new DateTimeZone($this->config->timezone);
184
    }
185
186 2174
    public function getSchemaHandler(): HandlerInterface
187
    {
188 2174
        // do not allow to carry prepared statements between schema changes
189
        $this->queryCache = [];
190
191
        return $this->schemaHandler;
192
    }
193
194
    public function getQueryCompiler(): CompilerInterface
195
    {
196 70
        return $this->queryCompiler;
197
    }
198 70
199 70
    public function getQueryBuilder(): BuilderInterface
200
    {
201
        return $this->queryBuilder;
202
    }
203
204 24
    /**
205
     * Force driver connection.
206 24
     *
207
     * @throws DriverException
208
     */
209
    public function connect(): void
210
    {
211
        $this->pdo ??= $this->createPDO();
212 10
    }
213
214
    /**
215 10
     * Check if driver already connected.
216 10
     */
217
    public function isConnected(): bool
218
    {
219
        return $this->pdo !== null;
220
    }
221
222 10
    /**
223 10
     * Disconnect driver.
224
     */
225
    public function disconnect(): void
226
    {
227
        try {
228 1030
            $this->queryCache = [];
229
            $this->pdo = null;
230 1030
        } 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...
231 32
            // disconnect error
232
            $this->logger?->error($e->getMessage());
233
        }
234 1030
235
        $this->transactionLevel = 0;
236
    }
237
238
    /**
239
     * @psalm-return non-empty-string
240
     */
241
    public function quote(mixed $value, int $type = PDO::PARAM_STR): string
242
    {
243
        /** @since PHP 8.1 */
244 3758
        if ($value instanceof BackedEnum) {
245
            $value = (string)$value->value;
246 3758
        }
247
248
        if ($value instanceof DateTimeInterface) {
249
            $value = $this->formatDatetime($value);
250
        }
251
252
        return $this->getPDO()->quote($value, $type);
253
    }
254
255
    /**
256
     * Execute query and return query statement.
257 1954
     *
258
     * @psalm-param non-empty-string $statement
259 1954
     *
260 80
     * @throws StatementException
261
     */
262
    public function query(string $statement, array $parameters = []): StatementInterface
263 1954
    {
264
        return $this->statement($statement, $parameters);
265
    }
266
267
    /**
268
     * Execute query and return number of affected rows.
269
     *
270
     * @psalm-param non-empty-string $query
271
     *
272
     * @throws StatementException
273
     * @throws ReadonlyConnectionException
274 274
     */
275
    public function execute(string $query, array $parameters = []): int
276 274
    {
277 274
        if ($this->isReadonly()) {
278
            throw ReadonlyConnectionException::onWriteStatementExecution();
279 274
        }
280
281
        return $this->statement($query, $parameters)->rowCount();
282 6
    }
283
284 6
    /**
285
     * Get id of last inserted row, this method must be called after insert query. Attention,
286
     * such functionality may not work in some DBMS property (Postgres).
287
     *
288
     * @param string|null $sequence Name of the sequence object from which the ID should be returned.
289
     *
290
     * @return mixed
291
     */
292
    public function lastInsertID(string $sequence = null)
293
    {
294
        $result = $this->getPDO()->lastInsertId();
295
        $this->logger?->debug("Insert ID: {$result}");
296 164
297
        return $result;
298 164
    }
299
300 164
    public function getTransactionLevel(): int
301 164
    {
302 24
        return $this->transactionLevel;
303
    }
304
305 164
    /**
306
     * Start SQL transaction with specified isolation level (not all DBMS support it). Nested
307
     * transactions are processed using savepoints.
308 164
     *
309
     * @link http://en.wikipedia.org/wiki/Database_transaction
310
     * @link http://en.wikipedia.org/wiki/Isolation_(database_systems)
311
     *
312
     * @param string|null $isolationLevel
313
     */
314
    public function beginTransaction(string $isolationLevel = null): bool
315
    {
316
        ++$this->transactionLevel;
317
318
        if ($this->transactionLevel === 1) {
319
            if ($isolationLevel !== null) {
320
                $this->setIsolationLevel($isolationLevel);
321
            }
322
323
            $this->logger?->info('Begin transaction');
324
325
            try {
326
                return $this->getPDO()->beginTransaction();
327
            } catch (Throwable  $e) {
328
                $e = $this->mapException($e, 'BEGIN TRANSACTION');
329
330
                if (
331 24
                    $e instanceof StatementException\ConnectionException
332
                    && $this->config->reconnect
333 24
                ) {
334
                    $this->disconnect();
335
336
                    try {
337
                        $this->transactionLevel = 1;
338
                        return $this->getPDO()->beginTransaction();
339
                    } catch (Throwable $e) {
340
                        $this->transactionLevel = 0;
341 192
                        throw $this->mapException($e, 'BEGIN TRANSACTION');
342
                    }
343
                } else {
344 192
                    $this->transactionLevel = 0;
345 30
                    throw $e;
346 30
                }
347 30
            }
348 30
        }
349
350
        $this->createSavepoint($this->transactionLevel);
351
352 30
        return true;
353 2
    }
354
355
    /**
356 28
     * Commit the active database transaction.
357 28
     *
358
     * @throws StatementException
359
     */
360 162
    public function commitTransaction(): bool
361
    {
362 162
        // Check active transaction
363 162
        if (!$this->getPDO()->inTransaction()) {
364
            $this->logger?->warning(
365
                sprintf(
366 162
                    'Attempt to commit a transaction that has not yet begun. Transaction level: %d',
367
                    $this->transactionLevel
368
                )
369
            );
370
371
            if ($this->transactionLevel === 0) {
372 8
                return false;
373
            }
374 8
375
            $this->transactionLevel = 0;
376
            return true;
377
        }
378
379
        --$this->transactionLevel;
380
381
        if ($this->transactionLevel === 0) {
382 40
            $this->logger?->info('Commit transaction');
383
384
            try {
385 40
                return $this->getPDO()->commit();
386 2
            } catch (Throwable $e) {
387 2
                throw $this->mapException($e, 'COMMIT TRANSACTION');
388 2
            }
389 2
        }
390
391
        $this->releaseSavepoint($this->transactionLevel + 1);
392
393 2
        return true;
394 2
    }
395
396
    /**
397 38
     * Rollback the active database transaction.
398
     *
399 38
     * @throws StatementException
400 24
     */
401
    public function rollbackTransaction(): bool
402
    {
403 24
        // Check active transaction
404
        if (!$this->getPDO()->inTransaction()) {
405
            $this->logger?->warning(
406
                sprintf(
407
                    'Attempt to rollback a transaction that has not yet begun. Transaction level: %d',
408
                    $this->transactionLevel
409 22
                )
410
            );
411 22
412
            $this->transactionLevel = 0;
413
            return false;
414
        }
415
416
        --$this->transactionLevel;
417 3460
418
        if ($this->transactionLevel === 0) {
419 3460
            $this->logger?->info('Rollback transaction');
420
421
            try {
422
                return $this->getPDO()->rollBack();
423
            } catch (Throwable  $e) {
424
                throw $this->mapException($e, 'ROLLBACK TRANSACTION');
425
            }
426
        }
427
428
        $this->rollbackSavepoint($this->transactionLevel + 1);
429
430 3758
        return true;
431
    }
432 3758
433
    /**
434
     * @psalm-param non-empty-string $identifier
435 3758
     */
436 3758
    public function identifier(string $identifier): string
437
    {
438 3758
        return $this->queryCompiler->quoteIdentifier($identifier);
439 36
    }
440 36
441
    /**
442
     * Create instance of PDOStatement using provided SQL query and set of parameters and execute
443 36
     * it. Will attempt singular reconnect.
444 36
     *
445 36
     * @psalm-param non-empty-string $query
446
     *
447 2
     * @throws StatementException
448
     */
449 2
    protected function statement(string $query, iterable $parameters = [], bool $retry = true): StatementInterface
450
    {
451
        $queryStart = \microtime(true);
452 34
453
        try {
454 3758
            $statement = $this->bindParameters($this->prepare($query), $parameters);
455 3724
            $statement->execute();
456 3724
457
            return new Statement($statement);
458 3724
        } catch (Throwable $e) {
459 36
            $e = $this->mapException($e, Interpolator::interpolate($query, $parameters));
460 36
461
            if (
462 3724
                $retry
463
                && $this->transactionLevel === 0
464
                && $e instanceof StatementException\ConnectionException
465
            ) {
466
                $this->disconnect();
467
468
                return $this->statement($query, $parameters, false);
469
            }
470
471 3758
            throw $e;
472
        } finally {
473 3758
            if ($this->logger !== null) {
474 1360
                $queryString = $this->config->options['logQueryParameters']
475
                    ? Interpolator::interpolate($query, $parameters)
476
                    : $query;
477 3758
                $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

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