Completed
Push — 2.x ( c394f3...5e9c18 )
by Aleksei
18s queued 14s
created

Driver::setIsolationLevel()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

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

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