Test Failed
Pull Request — 2.x (#38)
by butschster
02:35
created

Driver::getName()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

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

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