Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
100.00% |
122 / 122 |
|
100.00% |
10 / 10 |
CRAP | |
100.00% |
1 / 1 |
MydbQueryBuilder | |
100.00% |
122 / 122 |
|
100.00% |
10 / 10 |
69 | |
100.00% |
1 / 1 |
__construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
showColumnsLike | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
3 | |||
showKeys | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
insertOne | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
4 | |||
buildUpdateWhereMany | |
100.00% |
19 / 19 |
|
100.00% |
1 / 1 |
10 | |||
buildUpdateWhere | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
6 | |||
buildDeleteWhere | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
4 | |||
buildWhere | |
100.00% |
36 / 36 |
|
100.00% |
1 / 1 |
15 | |||
buildInsertMany | |
100.00% |
17 / 17 |
|
100.00% |
1 / 1 |
7 | |||
escape | |
100.00% |
20 / 20 |
|
100.00% |
1 / 1 |
17 |
1 | <?php |
2 | /** |
3 | * This file is part of the sshilko/php-sql-mydb package. |
4 | * |
5 | * (c) Sergei Shilko <contact@sshilko.com> |
6 | * |
7 | * MIT License |
8 | * |
9 | * For the full copyright and license information, please view the LICENSE |
10 | * file that was distributed with this source code. |
11 | * @license https://opensource.org/licenses/mit-license.php MIT |
12 | */ |
13 | |
14 | declare(strict_types = 1); |
15 | |
16 | namespace sql; |
17 | |
18 | use sql\MydbException\QueryBuilderEscapeException; |
19 | use sql\MydbException\QueryBuilderException; |
20 | use sql\MydbMysqli\MydbMysqliEscapeStringInterface; |
21 | use function array_map; |
22 | use function count; |
23 | use function implode; |
24 | use function in_array; |
25 | use function is_array; |
26 | use function is_float; |
27 | use function is_int; |
28 | use function is_null; |
29 | use function is_object; |
30 | use function is_string; |
31 | use function is_subclass_of; |
32 | use function key; |
33 | use function preg_match; |
34 | use function sprintf; |
35 | use function strlen; |
36 | use function strpos; |
37 | use function strtoupper; |
38 | use function substr; |
39 | use function trim; |
40 | |
41 | /** |
42 | * @author Sergei Shilko <contact@sshilko.com> |
43 | * @license https://opensource.org/licenses/mit-license.php MIT |
44 | * @see https://github.com/sshilko/php-sql-mydb |
45 | */ |
46 | class MydbQueryBuilder implements MydbQueryBuilderInterface |
47 | { |
48 | |
49 | public function __construct(protected MydbMysqliEscapeStringInterface $mysqli) |
50 | { |
51 | } |
52 | |
53 | /** |
54 | * @throws \sql\MydbException\QueryBuilderException |
55 | */ |
56 | public function showColumnsLike(string $table, string $column): string |
57 | { |
58 | if ('' === $table || '' === $column) { |
59 | throw new QueryBuilderException(); |
60 | } |
61 | |
62 | return "SHOW COLUMNS FROM " . $this->escape($table, '') . " LIKE " . $this->escape($column); |
63 | } |
64 | |
65 | /** |
66 | * @throws \sql\MydbException\QueryBuilderException |
67 | */ |
68 | public function showKeys(string $table): string |
69 | { |
70 | if ('' === $table) { |
71 | throw new QueryBuilderException(); |
72 | } |
73 | |
74 | return 'SHOW KEYS FROM ' . $this->escape($table, ''); |
75 | } |
76 | |
77 | /** |
78 | * @param array<string, (float|int|\sql\MydbExpressionInterface|string|null)> $data |
79 | * @throws \sql\MydbException\QueryBuilderException |
80 | * @psalm-return string |
81 | */ |
82 | public function insertOne(array $data, string $table, string $type): string |
83 | { |
84 | if ('' === $table || 0 === count($data)) { |
85 | throw new QueryBuilderException(); |
86 | } |
87 | |
88 | $names = $values = []; |
89 | |
90 | foreach ($data as $name => $value) { |
91 | $names[] = $this->escape($name, ""); |
92 | $values[] = $this->escape($value); |
93 | } |
94 | |
95 | return sprintf('%s INTO %s (%s) VALUES (%s)', $type, $table, implode(',', $names), implode(',', $values)); |
96 | } |
97 | |
98 | /** |
99 | * @param array $columnSetWhere ['col1' => [ ['current1', 'new1'], ['current2', 'new2']] |
100 | * @param array $where ['col2' => 'value2', 'col3' => ['v3', 'v4']] |
101 | * @param string $table 'mytable' |
102 | * @throws \sql\MydbException\QueryBuilderException |
103 | * @phpcs:disable SlevomatCodingStandard.Complexity.Cognitive.ComplexityTooHigh |
104 | */ |
105 | public function buildUpdateWhereMany(array $columnSetWhere, array $where, string $table): string |
106 | { |
107 | if ('' === $table) { |
108 | throw new QueryBuilderException(); |
109 | } |
110 | |
111 | $sql = 'UPDATE ' . $table; |
112 | /** |
113 | * @phpcs:disable Generic.Files.LineLength.TooLong |
114 | * @var array<array-key, array<array-key, array<array-key, (float|int|string|\sql\MydbExpressionInterface|null)>>> $columnSetWhere |
115 | */ |
116 | foreach ($columnSetWhere as $column => $updateValuesMap) { |
117 | /** |
118 | * @psalm-suppress DocblockTypeContradiction |
119 | */ |
120 | if (!is_string($column) || !is_array($updateValuesMap) || 0 === count($updateValuesMap)) { |
121 | throw new QueryBuilderException(); |
122 | } |
123 | /** |
124 | * @psalm-suppress InvalidOperand |
125 | */ |
126 | $sql .= ' SET ' . $column . ' = CASE'; |
127 | |
128 | foreach ($updateValuesMap as $newValueWhere) { |
129 | if (!isset($newValueWhere[0], $newValueWhere[1]) || 2 !== count($newValueWhere)) { |
130 | throw new QueryBuilderException(); |
131 | } |
132 | |
133 | $escapedWhereValue = $this->escape($newValueWhere[0]); |
134 | $escapedThenValue = $this->escape($newValueWhere[1]); |
135 | |
136 | /** |
137 | * @psalm-suppress InvalidOperand |
138 | */ |
139 | $sql .= ' WHEN (' . $column . ' = ' . $escapedWhereValue . ')'; |
140 | $sql .= ' THEN ' . $escapedThenValue; |
141 | } |
142 | |
143 | /** |
144 | * @psalm-suppress InvalidOperand |
145 | */ |
146 | $sql .= ' ELSE ' . $column; |
147 | } |
148 | |
149 | $sql .= ' END'; |
150 | |
151 | if (count($where) > 0) { |
152 | $sql .= ' ' . $this->buildWhere($where); |
153 | } |
154 | |
155 | return $sql; |
156 | } |
157 | |
158 | /** |
159 | * @throws \sql\MydbException\QueryBuilderException |
160 | * @param array<string, (float|int|string|\sql\MydbExpressionInterface|null)> $update |
161 | */ |
162 | public function buildUpdateWhere( |
163 | array $update, |
164 | array $whereFields, |
165 | string $table, |
166 | array $whereNotFields = [], |
167 | ): ?string { |
168 | if ('' === $table || [] === $update || is_int(key($update))) { |
169 | throw new QueryBuilderException(); |
170 | } |
171 | |
172 | $values = []; |
173 | $queryWhere = $this->buildWhere($whereFields, $whereNotFields); |
174 | |
175 | foreach ($update as $field => $value) { |
176 | |
177 | /** |
178 | * @psalm-suppress RedundantCastGivenDocblockType |
179 | */ |
180 | $f = (string) $field . ' = ' . $this->escape($value); |
181 | $values[] = $f; |
182 | } |
183 | |
184 | $queryUpdate = implode(', ', $values); |
185 | |
186 | $result = 'UPDATE ' . $table . ' SET ' . $queryUpdate; |
187 | if ('' !== $queryWhere) { |
188 | $result .= ' ' . $queryWhere; |
189 | } |
190 | |
191 | return $result; |
192 | } |
193 | |
194 | /** |
195 | * @throws \sql\MydbException\QueryBuilderException |
196 | */ |
197 | public function buildDeleteWhere(string $table, array $fields = [], array $negativeFields = []): ?string |
198 | { |
199 | if ('' === $table || 0 === count($fields) || !is_string(key($fields))) { |
200 | throw new QueryBuilderException(); |
201 | } |
202 | |
203 | $queryWhere = $this->buildWhere($fields, $negativeFields); |
204 | |
205 | /** @lang text */ |
206 | return 'DELETE FROM ' . $this->escape($table, '') . ' ' . $queryWhere; |
207 | } |
208 | |
209 | /** |
210 | * @throws \sql\MydbException\QueryBuilderException |
211 | * @todo will this need real db connection to escape()? add test for all possible cases |
212 | * @phpcs:disable SlevomatCodingStandard.Complexity.Cognitive.ComplexityTooHigh |
213 | */ |
214 | public function buildWhere(array $fields, array $negativeFields = [], array $likeFields = []): string |
215 | { |
216 | if ([] === $fields) { |
217 | throw new QueryBuilderException(); |
218 | } |
219 | |
220 | $where = []; |
221 | |
222 | /** |
223 | * @psalm-var float|int|string|array|\sql\MydbExpressionInterface|null $value |
224 | */ |
225 | foreach ($fields as $field => $value) { |
226 | /** |
227 | * @psalm-suppress InvalidOperand |
228 | */ |
229 | $queryPart = (string) $field; |
230 | $isNegative = in_array($field, $negativeFields, true); |
231 | $inNull = false; |
232 | |
233 | /** |
234 | * @TODO Expression support? |
235 | */ |
236 | |
237 | if (null === $value) { |
238 | $queryPart .= ' IS ' . ($isNegative ? 'NOT ' : '') . 'NULL'; |
239 | } elseif (is_array($value)) { |
240 | $queryPart .= ($isNegative ? ' NOT' : '') . " IN ("; |
241 | $inVals = []; |
242 | |
243 | /** |
244 | * @psalm-var float|int|string|\sql\MydbExpressionInterface|null $val |
245 | */ |
246 | foreach ($value as $val) { |
247 | if (null === $val) { |
248 | $inNull = true; |
249 | } else { |
250 | $inValEscaped = $this->escape($val); |
251 | $inVals[] = $inValEscaped; |
252 | } |
253 | } |
254 | |
255 | $queryPart .= implode(',', $inVals) . ')'; |
256 | } else { |
257 | $equality = ($isNegative ? '!' : '') . "="; |
258 | |
259 | if (in_array($field, $likeFields, true)) { |
260 | $equality = ($isNegative ? ' NOT ' : ' ') . "LIKE "; |
261 | } |
262 | |
263 | $queryPart .= $equality; |
264 | $queryPartEscaped = $this->escape($value); |
265 | $queryPart .= $queryPartEscaped; |
266 | } |
267 | |
268 | if ($inNull) { |
269 | $queryPart = sprintf( |
270 | ' (%s %s %s IS %s) ', |
271 | $queryPart, |
272 | $isNegative ? 'AND' : 'OR', |
273 | $field, |
274 | $isNegative ? 'NOT NULL' : 'NULL', |
275 | ); |
276 | } |
277 | |
278 | $where[] = $queryPart; |
279 | } |
280 | |
281 | $condition = []; |
282 | $condition[] = implode(' AND ', $where); |
283 | |
284 | return 'WHERE ' . trim(implode(' AND ', $condition)); |
285 | } |
286 | |
287 | /** |
288 | * @throws \sql\MydbException\QueryBuilderException |
289 | * @see https://dev.mysql.com/doc/refman/8.0/en/insert-on-duplicate.html |
290 | * @param array<string> $cols |
291 | */ |
292 | public function buildInsertMany(array $data, array $cols, string $table, bool $ignore, string $onDuplicate): string |
293 | { |
294 | if ('' === $table || [] === $data || [] === $cols) { |
295 | throw new QueryBuilderException(); |
296 | } |
297 | |
298 | /** |
299 | * @phpcs:disable SlevomatCodingStandard.Functions.DisallowArrowFunction |
300 | * @throws \sql\MydbException\QueryBuilderException |
301 | */ |
302 | $mapper = function (array $item): string { |
303 | $escapedArgs = implode( |
304 | ', ', |
305 | /** |
306 | * @psalm-var float|int|string|\sql\MydbExpressionInterface|null $input |
307 | * @throws \sql\MydbException\QueryBuilderException |
308 | */ |
309 | array_map(function ($input) { |
310 | /** |
311 | * @psalm-var float|int|string|\sql\MydbExpressionInterface|null $input |
312 | * @phan-suppress-next-line PhanThrowTypeAbsentForCall |
313 | */ |
314 | return $this->escape($input); |
315 | }, $item), |
316 | ); |
317 | |
318 | return '(' . $escapedArgs . ')'; |
319 | }; |
320 | |
321 | $values = array_map($mapper, $data); |
322 | |
323 | $query = "INSERT " . ($ignore ? 'IGNORE ' : '') . "INTO " . $table . " "; |
324 | $query .= "(" . implode(', ', $cols) . ") VALUES " . implode(', ', $values); |
325 | |
326 | if ('' !== $onDuplicate && false === $ignore) { |
327 | $query .= ' ON DUPLICATE KEY UPDATE ' . $onDuplicate; |
328 | } |
329 | |
330 | return $query; |
331 | } |
332 | |
333 | /** |
334 | * @param float|int|string|\sql\MydbExpressionInterface|null $unescaped |
335 | * @SuppressWarnings(PHPMD.NPathComplexity) |
336 | * @throws \sql\MydbException\QueryBuilderException |
337 | * @todo reduce NPathComplexity |
338 | * @phpcs:disable SlevomatCodingStandard.Complexity.Cognitive.ComplexityTooHigh |
339 | */ |
340 | public function escape($unescaped, string $quote = "'"): string |
341 | { |
342 | if (is_float($unescaped)) { |
343 | return (string) $unescaped; |
344 | } |
345 | |
346 | if (is_int($unescaped)) { |
347 | return (string) $unescaped; |
348 | } |
349 | |
350 | /** |
351 | * Not quoting '0x...' decimal values |
352 | */ |
353 | if (is_string($unescaped) && 0 === strpos($unescaped, '0x') && preg_match('/^[a-zA-Z0-9]+$/', $unescaped)) { |
354 | if (0 === strlen($unescaped) % 2) { |
355 | return '0x' . strtoupper(substr($unescaped, 2)); |
356 | } |
357 | } |
358 | |
359 | if (is_object($unescaped)) { |
360 | /** |
361 | * PHP <=7.4 |
362 | */ |
363 | if ($unescaped instanceof MydbExpressionInterface) { |
364 | return (string) $unescaped; |
365 | } |
366 | |
367 | /** |
368 | * PHP >=8.0 |
369 | * @psalm-suppress ArgumentTypeCoercion |
370 | */ |
371 | if (is_subclass_of($unescaped, 'Stringable')) { |
372 | return (string) $unescaped; |
373 | } |
374 | } |
375 | |
376 | if (is_null($unescaped)) { |
377 | return '' !== $quote ? $quote . '' . $quote : ''; |
378 | } |
379 | |
380 | /** |
381 | * @psalm-suppress RedundantCastGivenDocblockType |
382 | */ |
383 | if (preg_match('/^(\w)*$/', (string) $unescaped) || preg_match('/^(\w\s)*$/', (string) $unescaped)) { |
384 | return '' !== $quote ? $quote . ((string) $unescaped) . $quote : (string) $unescaped; |
385 | } |
386 | |
387 | /** |
388 | * @psalm-suppress RedundantCastGivenDocblockType |
389 | */ |
390 | $result = $this->mysqli->realEscapeString((string) $unescaped); |
391 | if (null === $result) { |
392 | /** |
393 | * @psalm-suppress RedundantCastGivenDocblockType |
394 | */ |
395 | throw new QueryBuilderException((new QueryBuilderEscapeException((string) $unescaped))->getMessage()); |
396 | } |
397 | |
398 | return '' !== $quote ? $quote . $result . $quote : $result; |
399 | } |
400 | } |