Passed
Pull Request — 2.x (#67)
by Aleksei
18:49
created

Driver::bindParameters()   B

Complexity

Conditions 7
Paths 25

Size

Total Lines 32
Code Lines 17

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 11
CRAP Score 7.0283

Importance

Changes 0
Metric Value
cc 7
eloc 17
nc 25
nop 2
dl 0
loc 32
ccs 11
cts 12
cp 0.9167
crap 7.0283
rs 8.8333
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 IntBackedEnum;
0 ignored issues
show
Bug introduced by
The type IntBackedEnum 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...
30
use PDO;
31
use PDOStatement;
32
use Psr\Log\LoggerAwareInterface;
33
use Psr\Log\LoggerAwareTrait;
34
use Throwable;
35
36
/**
37
 * Provides low level abstraction at top of
38
 */
39
abstract class Driver implements DriverInterface, NamedInterface, LoggerAwareInterface
40
{
41
    use LoggerAwareTrait;
42
43
    /**
44
     * DateTime format to be used to perform automatic conversion of DateTime objects.
45
     *
46
     * @var non-empty-string (Typehint required for overriding behaviour)
47
     */
48
    protected const DATETIME = 'Y-m-d H:i:s';
49
    protected ?\PDO $pdo = null;
50
    protected int $transactionLevel = 0;
51
    protected HandlerInterface $schemaHandler;
52
    protected BuilderInterface $queryBuilder;
53
54
    /** @var PDOStatement[] */
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
                        return $this->getPDO()->beginTransaction();
327
                    } catch (Throwable $e) {
328
                        $this->transactionLevel = 0;
329
                        throw $this->mapException($e, 'BEGIN TRANSACTION');
330
                    }
331 24
                } else {
332
                    $this->transactionLevel = 0;
333 24
                    throw $e;
334
                }
335
            }
336
        }
337
338
        $this->createSavepoint($this->transactionLevel);
339
340
        return true;
341 192
    }
342
343
    /**
344 192
     * Commit the active database transaction.
345 30
     *
346 30
     * @throws StatementException
347 30
     */
348 30
    public function commitTransaction(): bool
349
    {
350
        // Check active transaction
351
        if (!$this->getPDO()->inTransaction()) {
352 30
            $this->logger?->warning(
353 2
                sprintf(
354
                    'Attempt to commit a transaction that has not yet begun. Transaction level: %d',
355
                    $this->transactionLevel
356 28
                )
357 28
            );
358
359
            if ($this->transactionLevel === 0) {
360 162
                return false;
361
            }
362 162
363 162
            $this->transactionLevel = 0;
364
            return true;
365
        }
366 162
367
        --$this->transactionLevel;
368
369
        if ($this->transactionLevel === 0) {
370
            $this->logger?->info('Commit transaction');
371
372 8
            try {
373
                return $this->getPDO()->commit();
374 8
            } catch (Throwable $e) {
375
                throw $this->mapException($e, 'COMMIT TRANSACTION');
376
            }
377
        }
378
379
        $this->releaseSavepoint($this->transactionLevel + 1);
380
381
        return true;
382 40
    }
383
384
    /**
385 40
     * Rollback the active database transaction.
386 2
     *
387 2
     * @throws StatementException
388 2
     */
389 2
    public function rollbackTransaction(): bool
390
    {
391
        // Check active transaction
392
        if (!$this->getPDO()->inTransaction()) {
393 2
            $this->logger?->warning(
394 2
                sprintf(
395
                    'Attempt to rollback a transaction that has not yet begun. Transaction level: %d',
396
                    $this->transactionLevel
397 38
                )
398
            );
399 38
400 24
            $this->transactionLevel = 0;
401
            return false;
402
        }
403 24
404
        --$this->transactionLevel;
405
406
        if ($this->transactionLevel === 0) {
407
            $this->logger?->info('Rollback transaction');
408
409 22
            try {
410
                return $this->getPDO()->rollBack();
411 22
            } catch (Throwable  $e) {
412
                throw $this->mapException($e, 'ROLLBACK TRANSACTION');
413
            }
414
        }
415
416
        $this->rollbackSavepoint($this->transactionLevel + 1);
417 3460
418
        return true;
419 3460
    }
420
421
    /**
422
     * @psalm-param non-empty-string $identifier
423
     */
424
    public function identifier(string $identifier): string
425
    {
426
        return $this->queryCompiler->quoteIdentifier($identifier);
427
    }
428
429
    /**
430 3758
     * Create instance of PDOStatement using provided SQL query and set of parameters and execute
431
     * it. Will attempt singular reconnect.
432 3758
     *
433
     * @psalm-param non-empty-string $query
434
     *
435 3758
     * @throws StatementException
436 3758
     */
437
    protected function statement(string $query, iterable $parameters = [], bool $retry = true): StatementInterface
438 3758
    {
439 36
        $queryStart = \microtime(true);
440 36
441
        try {
442
            $statement = $this->bindParameters($this->prepare($query), $parameters);
443 36
            $statement->execute();
444 36
445 36
            return new Statement($statement);
446
        } catch (Throwable $e) {
447 2
            $e = $this->mapException($e, Interpolator::interpolate($query, $parameters));
448
449 2
            if (
450
                $retry
451
                && $this->transactionLevel === 0
452 34
                && $e instanceof StatementException\ConnectionException
453
            ) {
454 3758
                $this->disconnect();
455 3724
456 3724
                return $this->statement($query, $parameters, false);
457
            }
458 3724
459 36
            throw $e;
460 36
        } finally {
461
            if ($this->logger !== null) {
462 3724
                $queryString = Interpolator::interpolate($query, $parameters);
463
                $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

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