Passed
Push — master ( 91edba...d01bf5 )
by Wilmer
02:58
created

LuaScriptBuilder::build()   D

Complexity

Conditions 12
Paths 320

Size

Total Lines 73
Code Lines 42

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 31
CRAP Score 12.215

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 12
eloc 42
nc 320
nop 3
dl 0
loc 73
ccs 31
cts 35
cp 0.8857
crap 12.215
rs 4.6333
c 1
b 0
f 0

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\ActiveRecord\Redis;
6
7
use Yiisoft\Db\Exception\Exception;
8
use Yiisoft\Db\Exception\InvalidParamException;
9
use Yiisoft\Db\Exception\NotSupportedException;
10
use Yiisoft\Db\Expression\Expression;
11
12
use function addcslashes;
13
use function array_shift;
14
use function count;
15
use function implode;
16
use function is_array;
17
use function is_bool;
18
use function is_int;
19
use function is_numeric;
20
use function is_string;
21
use function key;
22
use function preg_replace;
23
use function reset;
24
use function strtolower;
25
26
/**
27
 * LuaScriptBuilder builds lua scripts used for retrieving data from redis.
28
 */
29
final class LuaScriptBuilder
30
{
31
    /**
32
     * Builds a Lua script for finding a list of records.
33
     *
34
     * @param ActiveQuery $query the query used to build the script.
35
     *
36
     * @throws Exception
37
     * @throws NotSupportedException
38
     *
39
     * @return string
40
     */
41 36
    public function buildAll(ActiveQuery $query): string
42
    {
43
        /** @var $modelClass ActiveRecord */
44 36
        $modelClass = $query->getModelClass();
45
46 36
        $key = $this->quoteValue($modelClass::keyPrefix() . ':a:');
47
48 36
        return $this->build($query, "n=n+1 pks[n]=redis.call('HGETALL',$key .. pk)", 'pks');
49
    }
50
51
    /**
52
     * Builds a Lua script for finding one record.
53
     *
54
     * @param ActiveQuery $query the query used to build the script.
55
     *
56
     * @throws Exception
57
     * @throws NotSupportedException
58
     *
59
     * @return string
60
     */
61 18
    public function buildOne(ActiveQuery $query): string
62
    {
63
        /** @var $modelClass ActiveRecord */
64 18
        $modelClass = $query->getModelClass();
65
66 18
        $key = $this->quoteValue($modelClass::keyPrefix() . ':a:');
67
68 18
        return $this->build($query, "do return redis.call('HGETALL',$key .. pk) end", 'pks');
69
    }
70
71
    /**
72
     * Builds a Lua script for finding a column.
73
     *
74
     * @param ActiveQuery $query the query used to build the script.
75
     * @param string $column name of the column.
76
     *
77
     * @throws Exception
78
     * @throws NotSupportedException
79
     *
80
     * @return string
81
     */
82 1
    public function buildColumn(ActiveQuery $query, string $column): string
83
    {
84
        /**
85
         * TODO add support for indexBy.
86
         * @var $modelClass ActiveRecord
87
         */
88 1
        $modelClass = $query->getModelClass();
89
90 1
        $key = $this->quoteValue($modelClass::keyPrefix() . ':a:');
91
92 1
        return $this->build(
93
            $query,
94 1
            "n=n+1 pks[n]=redis.call('HGET',$key .. pk," . $this->quoteValue($column) . ")",
95 1
            'pks'
96
        );
97
    }
98
99
    /**
100
     * Builds a Lua script for getting count of records.
101
     *
102
     * @param ActiveQuery $query the query used to build the script.
103
     *
104
     * @throws Exception
105
     * @throws NotSupportedException
106
     *
107
     * @return string
108
     */
109 3
    public function buildCount(ActiveQuery $query): string
110
    {
111 3
        return $this->build($query, 'n=n+1', 'n');
112
    }
113
114
    /**
115
     * Builds a Lua script for finding the sum of a column.
116
     *
117
     * @param ActiveQuery $query the query used to build the script.
118
     * @param string $column name of the column.
119
     *
120
     * @throws Exception
121
     * @throws NotSupportedException
122
     *
123
     * @return string
124
     */
125 2
    public function buildSum(ActiveQuery $query, string $column): string
126
    {
127
        /** @var $modelClass ActiveRecord */
128 2
        $modelClass = $query->getModelClass();
129
130 2
        $key = $this->quoteValue($modelClass::keyPrefix() . ':a:');
131
132 2
        return $this->build($query, "n=n+redis.call('HGET',$key .. pk," . $this->quoteValue($column) . ")", 'n');
133
    }
134
135
    /**
136
     * Builds a Lua script for finding the average of a column.
137
     *
138
     * @param ActiveQuery $query the query used to build the script.
139
     * @param string $column name of the column.
140
     *
141
     * @throws Exception
142
     * @throws NotSupportedException
143
     *
144
     * @return string
145
     */
146 2
    public function buildAverage(ActiveQuery $query, string $column): string
147
    {
148
        /** @var $modelClass ActiveRecord */
149 2
        $modelClass = $query->getModelClass();
150
151 2
        $key = $this->quoteValue($modelClass::keyPrefix() . ':a:');
152
153 2
        return $this->build(
154
            $query,
155 2
            "n=n+1 if v==nil then v=0 end v=v+redis.call('HGET',$key .. pk," . $this->quoteValue($column) . ")",
156 2
            'v/n'
157
        );
158
    }
159
160
    /**
161
     * Builds a Lua script for finding the min value of a column.
162
     *
163
     * @param ActiveQuery $query the query used to build the script.
164
     * @param string $column name of the column.
165
     *
166
     * @throws Exception
167
     * @throws NotSupportedException
168
     *
169
     * @return string
170
     */
171 2
    public function buildMin(ActiveQuery $query, string $column): string
172
    {
173
        /* @var $modelClass ActiveRecord */
174 2
        $modelClass = $query->getModelClass();
175
176 2
        $key = $this->quoteValue($modelClass::keyPrefix() . ':a:');
177
178 2
        return $this->build(
179
            $query,
180 2
            "n=redis.call('HGET',$key .. pk," . $this->quoteValue($column) . ") if v==nil or n<v then v=n end",
181 2
            'v'
182
        );
183
    }
184
185
    /**
186
     * Builds a Lua script for finding the max value of a column.
187
     *
188
     * @param ActiveQuery $query the query used to build the script.
189
     * @param string $column name of the column.
190
     *
191
     * @throws Exception
192
     * @throws NotSupportedException
193
     *
194
     * @return string
195
     */
196 2
    public function buildMax(ActiveQuery $query, string $column): string
197
    {
198
        /** @var $modelClass ActiveRecord */
199 2
        $modelClass = $query->getModelClass();
200
201 2
        $key = $this->quoteValue($modelClass::keyPrefix() . ':a:');
202
203 2
        return $this->build(
204
            $query,
205 2
            "n=redis.call('HGET',$key .. pk," . $this->quoteValue($column) . ") if v==nil or n>v then v=n end",
206 2
            'v'
207
        );
208
    }
209
210
    /**
211
     * @param ActiveQuery $query the query used to build the script.
212
     * @param string $buildResult the lua script for building the result.
213
     * @param string $return the lua variable that should be returned.
214
     *
215
     * @throws Exception
216
     * @throws NotSupportedException when query contains unsupported order by condition.
217
     *
218
     * @return string
219
     */
220 56
    private function build(ActiveQuery $query, string $buildResult, string $return): string
221
    {
222 56
        $columns = [];
223
224 56
        if ($query->getWhere() !== null) {
225 47
            $condition = $this->buildCondition($query->getWhere(), $columns);
226
        } else {
227 17
            $condition = 'true';
228
        }
229
230 56
        $start = ($query->getOffset() === null || $query->getOffset() < 0) ? 0 : $query->getOffset();
231 56
        $limitCondition = 'i>' . $start . (
232 56
            ($query->getLimit() === null || $query->getLimit() < 0) ? '' : ' and i<=' . ($start + $query->getLimit())
233
        );
234
235
        /** @var $modelClass ActiveRecord */
236 56
        $modelClass = $query->getModelClass();
237
238 56
        $key = $this->quoteValue($modelClass::keyPrefix());
239
240 56
        $loadColumnValues = '';
241
242 56
        foreach ($columns as $column => $alias) {
243 46
            $loadColumnValues .= "local $alias=redis.call('HGET',$key .. ':a:' .. pk, "
244 46
                . $this->quoteValue($column) . ")\n";
245
        }
246
247
        $getAllPks = <<<EOF
248 56
local allpks=redis.call('LRANGE',$key,0,-1)
249
EOF;
250 56
        if (!empty($query->getOrderBy())) {
251 7
            if (!is_array($query->getOrderBy()) || count($query->getOrderBy()) > 1) {
0 ignored issues
show
introduced by
The condition is_array($query->getOrderBy()) is always true.
Loading history...
252
                throw new NotSupportedException(
253
                    'orderBy by multiple columns is not currently supported by redis ActiveRecord.'
254
                );
255
            }
256
257 7
            $k = key($query->getOrderBy());
258 7
            $v = $query->getOrderBy()[$k];
259
260 7
            if (is_numeric($k)) {
261
                $orderColumn = $v;
262
                $orderType = 'ASC';
263
            } else {
264 7
                $orderColumn = $k;
265 7
                $orderType = $v === SORT_DESC ? 'DESC' : 'ASC';
266
            }
267
268
            $getAllPks = <<<EOF
269 7
local allpks=redis.pcall('SORT', $key, 'BY', $key .. ':a:*->' .. '$orderColumn', '$orderType')
270
if allpks['err'] then
271 7
    allpks=redis.pcall('SORT', $key, 'BY', $key .. ':a:*->' .. '$orderColumn', '$orderType', 'ALPHA')
272
end
273
EOF;
274
        }
275
276
        return <<<EOF
277 56
$getAllPks
278
local pks={}
279
local n=0
280
local v=nil
281
local i=0
282 56
local key=$key
283
for k,pk in ipairs(allpks) do
284 56
    $loadColumnValues
285 56
    if $condition then
286
      i=i+1
287 56
      if $limitCondition then
288 56
        $buildResult
289
      end
290
    end
291
end
292 56
return $return
293
EOF;
294
    }
295
296
    /**
297
     * Adds a column to the list of columns to retrieve and creates an alias.
298
     *
299
     * @param string $column the column name to add.
300
     * @param array $columns list of columns given by reference.
301
     *
302
     * @return string the alias generated for the column name.
303
     */
304 46
    private function addColumn(string $column, array &$columns = []): string
305
    {
306 46
        if (isset($columns[$column])) {
307 2
            return $columns[$column];
308
        }
309
310 46
        $name = 'c' . preg_replace("/[^a-z]+/i", "", $column) . count($columns);
311
312 46
        return $columns[$column] = $name;
313
    }
314
315
    /**
316
     * Quotes a string value for use in a query.
317
     *
318
     * Note that if the parameter is not a string or int, it will be returned without change.
319
     *
320
     * @param string|int $str string to be quoted.
321
     *
322
     * @return string|int the properly quoted string.
323
     */
324 56
    private function quoteValue($str)
325
    {
326 56
        if (!is_string($str) && !is_int($str)) {
0 ignored issues
show
introduced by
The condition is_int($str) is always true.
Loading history...
327
            return $str;
328
        }
329
330 56
        return "'" . addcslashes((string) $str, "\000\n\r\\\032\047") . "'";
331
    }
332
333
    /**
334
     * Parses the condition specification and generates the corresponding Lua expression.
335
     *
336
     * @param string|array $condition the condition specification. Please refer to {@see ActiveQuery::where()} on how
337
     * to specify a condition.
338
     * @param array $columns the list of columns and aliases to be used.
339
     *
340
     * @throws Exception if the condition is in bad format.
341
     * @throws NotSupportedException if the condition is not an array.
342
     *
343
     * @return string the generated SQL expression.
344
     */
345 47
    public function buildCondition($condition, array &$columns = []): string
346
    {
347 47
        static $builders = [
348
            'not' => 'buildNotCondition',
349
            'and' => 'buildAndCondition',
350
            'or' => 'buildAndCondition',
351
            'between' => 'buildBetweenCondition',
352
            'not between' => 'buildBetweenCondition',
353
            'in' => 'buildInCondition',
354
            'not in' => 'buildInCondition',
355
            'like' => 'buildLikeCondition',
356
            'not like' => 'buildLikeCondition',
357
            'or like' => 'buildLikeCondition',
358
            'or not like' => 'buildLikeCondition',
359
        ];
360
361 47
        if (!is_array($condition)) {
362
            throw new NotSupportedException('Where condition must be an array in redis ActiveRecord.');
363
        }
364
        /** operator format: operator, operand 1, operand 2, ... */
365 47
        if (isset($condition[0])) {
366 28
            $operator = strtolower($condition[0]);
367 28
            if (isset($builders[$operator])) {
368 28
                $method = $builders[$operator];
369 28
                array_shift($condition);
370
371 28
                return $this->$method($operator, $condition, $columns);
372
            } else {
373
                throw new Exception('Found unknown operator in query: ' . $operator);
374
            }
375
        } else {
376
            /** hash format: 'column1' => 'value1', 'column2' => 'value2', ... */
377 29
            return $this->buildHashCondition($condition, $columns);
378
        }
379
    }
380
381 29
    private function buildHashCondition($condition, &$columns)
382
    {
383 29
        $parts = [];
384
385 29
        foreach ($condition as $column => $value) {
386
            /** IN condition */
387 29
            if (is_array($value)) {
388 6
                $parts[] = $this->buildInCondition('in', [$column, $value], $columns);
389
            } else {
390 24
                if (is_bool($value)) {
391 1
                    $value = (int) $value;
392
                }
393
394 24
                if ($value === null) {
395 1
                    $parts[] = "redis.call('HEXISTS',key .. ':a:' .. pk, " . $this->quoteValue($column) . ")==0";
396 23
                } elseif ($value instanceof Expression) {
397
                    $column = $this->addColumn($column, $columns);
398
399
                    $parts[] = "$column==" . $value->getExpression();
0 ignored issues
show
Bug introduced by
The method getExpression() does not exist on Yiisoft\Db\Expression\Expression. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

399
                    $parts[] = "$column==" . $value->/** @scrutinizer ignore-call */ getExpression();

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
400
                } else {
401 23
                    $column = $this->addColumn($column, $columns);
402 23
                    $value = $this->quoteValue((string) $value);
403
404 23
                    $parts[] = "$column==$value";
405
                }
406
            }
407
        }
408
409 29
        return count($parts) === 1 ? $parts[0] : '(' . implode(') and (', $parts) . ')';
410
    }
411
412 4
    private function buildNotCondition($operator, $operands, &$params)
413
    {
414 4
        if (count($operands) !== 1) {
415
            throw new InvalidParamException("Operator '$operator' requires exactly one operand.");
416
        }
417
418 4
        $operand = reset($operands);
419
420 4
        if (is_array($operand)) {
421 4
            $operand = $this->buildCondition($operand, $params);
422
        }
423
424 4
        return "$operator ($operand)";
425
    }
426
427 8
    private function buildAndCondition($operator, $operands, &$columns)
428
    {
429 8
        $parts = [];
430
431 8
        foreach ($operands as $operand) {
432 8
            if (is_array($operand)) {
433 8
                $operand = $this->buildCondition($operand, $columns);
434
            }
435 8
            if ($operand !== '') {
436 8
                $parts[] = $operand;
437
            }
438
        }
439
440 8
        if (!empty($parts)) {
441 8
            return '(' . implode(") $operator (", $parts) . ')';
442
        }
443
444
        return '';
445
    }
446
447 2
    private function buildBetweenCondition($operator, $operands, &$columns)
448
    {
449 2
        if (!isset($operands[0], $operands[1], $operands[2])) {
450
            throw new Exception("Operator '$operator' requires three operands.");
451
        }
452
453 2
        [$column, $value1, $value2] = $operands;
454
455 2
        $value1 = $this->quoteValue($value1);
456 2
        $value2 = $this->quoteValue($value2);
457 2
        $column = $this->addColumn($column, $columns);
458
459 2
        $condition = "$column >= $value1 and $column <= $value2";
460
461 2
        return $operator === 'not between' ? "not ($condition)" : $condition;
462
    }
463
464 29
    private function buildInCondition($operator, $operands, &$columns): string
465
    {
466 29
        if (!isset($operands[0], $operands[1])) {
467
            throw new Exception("Operator '$operator' requires two operands.");
468
        }
469
470 29
        [$column, $values] = $operands;
471
472 29
        $values = (array) $values;
473
474 29
        if (empty($values) || $column === []) {
475 3
            return $operator === 'in' ? 'false' : 'true';
476
        }
477
478 29
        if (is_array($column) && count($column) > 1) {
479
            return $this->buildCompositeInCondition($operator, $column, $values, $columns);
480
        }
481
482 29
        if (is_array($column)) {
483 21
            $column = reset($column);
484
        }
485
486 29
        $columnAlias = $this->addColumn((string) $column, $columns);
487 29
        $parts = [];
488
489 29
        foreach ($values as $value) {
490 29
            if (is_array($value)) {
491
                $value = $value[$column] ?? null;
492
            }
493
494 29
            if ($value === null) {
495
                $parts[] = "redis.call('HEXISTS',key .. ':a:' .. pk, " . $this->quoteValue($column) . ")==0";
496 29
            } elseif ($value instanceof Expression) {
497
                $parts[] = "$columnAlias==" . $value->getExpression();
498
            } else {
499 29
                $value = $this->quoteValue($value);
500 29
                $parts[] = "$columnAlias==$value";
501
            }
502
        }
503
504 29
        $operator = $operator === 'in' ? '' : 'not ';
505
506 29
        return "$operator(" . implode(' or ', $parts) . ')';
507
    }
508
509
    protected function buildCompositeInCondition($operator, $inColumns, $values, &$columns): string
510
    {
511
        $vss = [];
512
513
        foreach ($values as $value) {
514
            $vs = [];
515
            foreach ($inColumns as $column) {
516
                if (isset($value[$column])) {
517
                    $columnAlias = $this->addColumn($column, $columns);
518
                    $vs[] = "$columnAlias==" . $this->quoteValue($value[$column]);
519
                } else {
520
                    $vs[] = "redis.call('HEXISTS',key .. ':a:' .. pk, " . $this->quoteValue($column) . ")==0";
521
                }
522
            }
523
524
            $vss[] = '(' . implode(' and ', $vs) . ')';
525
        }
526
527
        $operator = $operator === 'in' ? '' : 'not ';
528
529
        return "$operator(" . implode(' or ', $vss) . ')';
530
    }
531
532
    private function buildLikeCondition($operator, $operands, &$columns)
533
    {
534
        throw new NotSupportedException('LIKE conditions are not suppoerted by redis ActiveRecord.');
535
    }
536
}
537