Passed
Pull Request — 2.x (#91)
by Aleksei
18:20
created

Driver::bindParameters()   A

Complexity

Conditions 6
Paths 17

Size

Total Lines 34
Code Lines 17

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 10
CRAP Score 6.1666

Importance

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

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