Test Failed
Pull Request — master (#83)
by Wilmer
16:31 queued 13:03
created

CommandPDOSqlite::extractUsedParams()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 16
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 9
dl 0
loc 16
rs 9.9666
c 1
b 0
f 0
cc 4
nc 4
nop 2
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Db\Sqlite\PDO;
6
7
use PDOException;
8
use Throwable;
9
use Yiisoft\Db\Cache\QueryCache;
10
use Yiisoft\Db\Command\Command;
11
use Yiisoft\Db\Connection\ConnectionPDOInterface;
12
use Yiisoft\Db\Exception\Exception;
13
use Yiisoft\Db\Exception\InvalidArgumentException;
14
use Yiisoft\Db\Query\QueryBuilderInterface;
0 ignored issues
show
Bug introduced by
The type Yiisoft\Db\Query\QueryBuilderInterface 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 Yiisoft\Db\Sqlite\SqlToken;
16
use Yiisoft\Db\Sqlite\SqlTokenizer;
17
use Yiisoft\Strings\StringHelper;
18
19
use function array_pop;
20
use function count;
21
use function ltrim;
22
use function preg_match_all;
23
use function strpos;
24
25
final class CommandPDOSqlite extends Command
26
{
27
    public function __construct(private ConnectionPDOInterface $db, QueryCache $queryCache)
28
    {
29
        parent::__construct($queryCache);
30
    }
31
32
    public function queryBuilder(): QueryBuilderInterface
33
    {
34
        return $this->db->getQueryBuilder();
35
    }
36
37
    public function prepare(?bool $forRead = null): void
38
    {
39
        if (isset($this->pdoStatement)) {
40
            $this->bindPendingParams();
41
42
            return;
43
        }
44
45
        $sql = $this->getSql() ?? '';
46
47
        if ($this->db->getTransaction()) {
48
            /** master is in a transaction. use the same connection. */
49
            $forRead = false;
50
        }
51
52
        if ($forRead || ($forRead === null && $this->db->getSchema()->isReadQuery($sql))) {
53
            $pdo = $this->db->getSlavePdo();
54
        } else {
55
            $pdo = $this->db->getMasterPdo();
56
        }
57
58
        try {
59
            $this->pdoStatement = $pdo?->prepare($sql);
60
            $this->bindPendingParams();
61
        } catch (PDOException $e) {
62
            $message = $e->getMessage() . "\nFailed to prepare SQL: $sql";
63
            $errorInfo = $e->errorInfo ?? null;
64
65
            throw new Exception($message, $errorInfo, $e);
66
        }
67
    }
68
69
    /**
70
     * Executes the SQL statement.
71
     *
72
     * This method should only be used for executing non-query SQL statement, such as `INSERT`, `DELETE`, `UPDATE` SQLs.
73
     * No result set will be returned.
74
     *
75
     * @throws Exception|Throwable execution failed.
76
     *
77
     * @return int number of rows affected by the execution.
78
     */
79
    public function execute(): int
80
    {
81
        $sql = $this->getSql() ?? '';
82
83
        /** @var array<string, string> */
84
        $params = $this->params;
85
86
        $statements = $this->splitStatements($sql, $params);
87
88
        if ($statements === false) {
0 ignored issues
show
introduced by
The condition $statements === false is always true.
Loading history...
89
            return parent::execute();
90
        }
91
92
        $result = 0;
93
94
        /** @psalm-var array<array-key, array<array-key, string|array>> $statements */
95
        foreach ($statements as $statement) {
96
            [$statementSql, $statementParams] = $statement;
97
            $statementSql = is_string($statementSql) ? $statementSql : '';
98
            $statementParams = is_array($statementParams) ? $statementParams : [];
99
            $this->setSql($statementSql)->bindValues($statementParams);
100
            $result = parent::execute();
101
        }
102
103
        $this->setSql($sql)->bindValues($params);
104
105
        return $result;
106
    }
107
108
    protected function getCacheKey(string $method, ?int $fetchMode, string $rawSql): array
109
    {
110
        return [
111
            __CLASS__,
112
            $method,
113
            $fetchMode,
114
            $this->db->getDriver()->getDsn(),
115
            $this->db->getDriver()->getUsername(),
116
            $rawSql,
117
        ];
118
    }
119
120
    protected function internalExecute(?string $rawSql): void
121
    {
122
        $attempt = 0;
123
124
        while (true) {
125
            try {
126
                if (
127
                    ++$attempt === 1
128
                    && $this->isolationLevel !== null
129
                    && $this->db->getTransaction() === null
130
                ) {
131
                    $this->db->transaction(fn (?string $rawSql) => $this->internalExecute($rawSql), $this->isolationLevel);
132
                } else {
133
                    $this->pdoStatement?->execute();
134
                }
135
                break;
136
            } catch (PDOException $e) {
137
                $rawSql = $rawSql ?: $this->getRawSql();
138
                $e = $this->db->getSchema()->convertException($e, $rawSql);
139
140
                if ($this->retryHandler === null || !($this->retryHandler)($e, $attempt)) {
141
                    throw $e;
142
                }
143
            }
144
        }
145
    }
146
147
    /**
148
     * Performs the actual DB query of a SQL statement.
149
     *
150
     * @param string $method method of PDOStatement to be called
151
     * @param array|int|null $fetchMode the result fetch mode.
152
     * Please refer to [PHP manual](http://www.php.net/manual/en/function.PDOStatement-setFetchMode.php) for valid fetch
153
     * modes. If this parameter is null, the value set in {@see fetchMode} will be used.
154
     *
155
     * @throws Exception|Throwable if the query causes any problem.
156
     *
157
     * @return mixed the method execution result.
158
     */
159
    protected function queryInternal(string $method, array|int $fetchMode = null): mixed
160
    {
161
        $sql = $this->getSql() ?? '';
162
163
        /** @var array<string, string> */
164
        $params = $this->params;
165
166
        $statements = $this->splitStatements($sql, $params);
167
168
        if ($statements === false || $statements === []) {
0 ignored issues
show
introduced by
The condition $statements === false is always true.
Loading history...
169
            return parent::queryInternal($method, $fetchMode);
170
        }
171
172
        [$lastStatementSql, $lastStatementParams] = array_pop($statements);
173
174
        /**
175
         * @psalm-var array<array-key, array> $statements
176
         */
177
        foreach ($statements as $statement) {
178
            /**
179
             * @var string $statementSql
180
             * @var array $statementParams
181
             */
182
            [$statementSql, $statementParams] = $statement;
183
            $this->setSql($statementSql)->bindValues($statementParams);
184
            parent::execute();
185
        }
186
187
        $this->setSql($lastStatementSql)->bindValues($lastStatementParams);
188
189
        /** @var string */
190
        $result = parent::queryInternal($method, $fetchMode);
191
192
        $this->setSql($sql)->bindValues($params);
193
194
        return $result;
195
    }
196
197
    /**
198
     * Splits the specified SQL codes into individual SQL statements and returns them or `false` if there's a single
199
     * statement.
200
     *
201
     * @param string $sql
202
     * @param array $params
203
     *
204
     * @throws InvalidArgumentException
205
     *
206
     * @return array|bool (array|string)[][]|bool
207
     *
208
     * @psalm-param array<string, string> $params
209
     * @psalm-return false|list<array{0: string, 1: array}>
210
     */
211
    private function splitStatements(string $sql, array $params): bool|array
212
    {
213
        $semicolonIndex = strpos($sql, ';');
214
215
        if ($semicolonIndex === false || $semicolonIndex === StringHelper::byteLength($sql) - 1) {
216
            return false;
217
        }
218
219
        $tokenizer = new SqlTokenizer($sql);
220
221
        $codeToken = $tokenizer->tokenize();
222
223
        if (count($codeToken->getChildren()) === 1) {
224
            return false;
225
        }
226
227
        $statements = [];
228
229
        foreach ($codeToken->getChildren() as $statement) {
230
            $statements[] = [$statement->getSql(), $this->extractUsedParams($statement, $params)];
231
        }
232
233
        return $statements;
234
    }
235
236
    /**
237
     * Returns named bindings used in the specified statement token.
238
     *
239
     * @param SqlToken $statement
240
     * @param array $params
241
     *
242
     * @return array
243
     *
244
     * @psalm-param array<string, string> $params
245
     */
246
    private function extractUsedParams(SqlToken $statement, array $params): array
247
    {
248
        preg_match_all('/(?P<placeholder>[:][a-zA-Z0-9_]+)/', $statement->getSql(), $matches, PREG_SET_ORDER);
249
250
        $result = [];
251
252
        foreach ($matches as $match) {
253
            $phName = ltrim($match['placeholder'], ':');
254
            if (isset($params[$phName])) {
255
                $result[$phName] = $params[$phName];
256
            } elseif (isset($params[':' . $phName])) {
257
                $result[':' . $phName] = $params[':' . $phName];
258
            }
259
        }
260
261
        return $result;
262
    }
263
}
264