Driver::__construct()   A
last analyzed

Complexity

Conditions 4
Paths 4

Size

Total Lines 17
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 6
CRAP Score 4.0466

Importance

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

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