Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
122 / 122
100.00% covered (success)
100.00%
10 / 10
CRAP
100.00% covered (success)
100.00%
1 / 1
MydbQueryBuilder
100.00% covered (success)
100.00%
122 / 122
100.00% covered (success)
100.00%
10 / 10
69
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 showColumnsLike
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 showKeys
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 insertOne
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 buildUpdateWhereMany
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
10
 buildUpdateWhere
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
6
 buildDeleteWhere
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 buildWhere
100.00% covered (success)
100.00%
36 / 36
100.00% covered (success)
100.00%
1 / 1
15
 buildInsertMany
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
7
 escape
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
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
14declare(strict_types = 1);
15
16namespace sql;
17
18use sql\MydbException\QueryBuilderEscapeException;
19use sql\MydbException\QueryBuilderException;
20use sql\MydbMysqli\MydbMysqliEscapeStringInterface;
21use function array_map;
22use function count;
23use function implode;
24use function in_array;
25use function is_array;
26use function is_float;
27use function is_int;
28use function is_null;
29use function is_object;
30use function is_string;
31use function is_subclass_of;
32use function key;
33use function preg_match;
34use function sprintf;
35use function strlen;
36use function strpos;
37use function strtoupper;
38use function substr;
39use 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 */
46class 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}