Passed
Push — master ( 5a7f24...599094 )
by Alexander
04:15
created

LuaScriptBuilder::buildMin()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 8
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 1

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 5
nc 1
nop 2
dl 0
loc 8
ccs 5
cts 5
cp 1
crap 1
rs 10
c 1
b 0
f 0
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|NotSupportedException
37
     *
38
     * @return string
39
     */
40 32
    public function buildAll(ActiveQuery $query): string
41
    {
42 32
        $key = $this->quoteValue($query->getARInstance()->keyPrefix() . ':a:');
0 ignored issues
show
Bug introduced by
The method keyPrefix() does not exist on Yiisoft\ActiveRecord\ActiveRecordInterface. It seems like you code against a sub-type of Yiisoft\ActiveRecord\ActiveRecordInterface such as Yiisoft\ActiveRecord\Redis\ActiveRecord. ( Ignorable by Annotation )

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

42
        $key = $this->quoteValue($query->getARInstance()->/** @scrutinizer ignore-call */ keyPrefix() . ':a:');
Loading history...
43
44 32
        return $this->build($query, "n=n+1 pks[n]=redis.call('HGETALL',$key .. pk)", 'pks');
45
    }
46
47
    /**
48
     * Builds a Lua script for finding one record.
49
     *
50
     * @param ActiveQuery $query the query used to build the script.
51
     *
52
     * @throws Exception|NotSupportedException
53
     *
54
     * @return string
55
     */
56 26
    public function buildOne(ActiveQuery $query): string
57
    {
58 26
        $key = $this->quoteValue($query->getARInstance()->keyPrefix() . ':a:');
59
60 26
        return $this->build($query, "do return redis.call('HGETALL',$key .. pk) end", 'pks');
61
    }
62
63
    /**
64
     * Builds a Lua script for finding a column.
65
     *
66
     * @param ActiveQuery $query the query used to build the script.
67
     * @param string $column name of the column.
68
     *
69
     * @throws Exception|NotSupportedException
70
     *
71
     * @return string
72
     */
73 1
    public function buildColumn(ActiveQuery $query, string $column): string
74
    {
75 1
        $key = $this->quoteValue($query->getARInstance()->keyPrefix() . ':a:');
76
77 1
        return $this->build(
78
            $query,
79 1
            "n=n+1 pks[n]=redis.call('HGET',$key .. pk," . $this->quoteValue($column) . ")",
80 1
            'pks'
81
        );
82
    }
83
84
    /**
85
     * Builds a Lua script for getting count of records.
86
     *
87
     * @param ActiveQuery $query the query used to build the script.
88
     *
89
     * @throws Exception|NotSupportedException
90
     *
91
     * @return string
92
     */
93 3
    public function buildCount(ActiveQuery $query): string
94
    {
95 3
        return $this->build($query, 'n=n+1', 'n');
96
    }
97
98
    /**
99
     * Builds a Lua script for finding the sum of a column.
100
     *
101
     * @param ActiveQuery $query the query used to build the script.
102
     * @param string $column name of the column.
103
     *
104
     * @throws Exception|NotSupportedException
105
     *
106
     * @return string
107
     */
108 2
    public function buildSum(ActiveQuery $query, string $column): string
109
    {
110 2
        $key = $this->quoteValue($query->getARInstance()->keyPrefix() . ':a:');
111
112 2
        return $this->build($query, "n=n+redis.call('HGET',$key .. pk," . $this->quoteValue($column) . ")", 'n');
113
    }
114
115
    /**
116
     * Builds a Lua script for finding the average of a column.
117
     *
118
     * @param ActiveQuery $query the query used to build the script.
119
     * @param string $column name of the column.
120
     *
121
     * @throws Exception|NotSupportedException
122
     *
123
     * @return string
124
     */
125 2
    public function buildAverage(ActiveQuery $query, string $column): string
126
    {
127 2
        $key = $this->quoteValue($query->getARInstance()->keyPrefix() . ':a:');
128
129 2
        return $this->build(
130
            $query,
131 2
            "n=n+1 if v==nil then v=0 end v=v+redis.call('HGET',$key .. pk," . $this->quoteValue($column) . ")",
132 2
            'v/n'
133
        );
134
    }
135
136
    /**
137
     * Builds a Lua script for finding the min value of a column.
138
     *
139
     * @param ActiveQuery $query the query used to build the script.
140
     * @param string $column name of the column.
141
     *
142
     * @throws Exception|NotSupportedException
143
     *
144
     * @return string
145
     */
146 2
    public function buildMin(ActiveQuery $query, string $column): string
147
    {
148 2
        $key = $this->quoteValue($query->getARInstance()->keyPrefix() . ':a:');
149
150 2
        return $this->build(
151
            $query,
152 2
            "n=redis.call('HGET',$key .. pk," . $this->quoteValue($column) . ") if v==nil or n<v then v=n end",
153 2
            'v'
154
        );
155
    }
156
157
    /**
158
     * Builds a Lua script for finding the max value of a column.
159
     *
160
     * @param ActiveQuery $query the query used to build the script.
161
     * @param string $column name of the column.
162
     *
163
     * @throws Exception|NotSupportedException
164
     *
165
     * @return string
166
     */
167 2
    public function buildMax(ActiveQuery $query, string $column): string
168
    {
169 2
        $key = $this->quoteValue($query->getARInstance()->keyPrefix() . ':a:');
170
171 2
        return $this->build(
172
            $query,
173 2
            "n=redis.call('HGET',$key .. pk," . $this->quoteValue($column) . ") if v==nil or n>v then v=n end",
174 2
            'v'
175
        );
176
    }
177
178
    /**
179
     * @param ActiveQuery $query the query used to build the script.
180
     * @param string $buildResult the lua script for building the result.
181
     * @param string $return the lua variable that should be returned.
182
     *
183
     * @throws Exception|NotSupportedException when query contains unsupported order by condition.
184
     *
185
     * @return string
186
     */
187 54
    private function build(ActiveQuery $query, string $buildResult, string $return): string
188
    {
189 54
        $columns = [];
190
191 54
        if ($query->getWhere() !== null) {
192 47
            $condition = $this->buildCondition($query->getWhere(), $columns);
193
        } else {
194 16
            $condition = 'true';
195
        }
196
197 54
        $start = ($query->getOffset() === null || $query->getOffset() < 0) ? 0 : $query->getOffset();
198 54
        $limitCondition = 'i>' . $start . (
0 ignored issues
show
Bug introduced by
Are you sure $start of type Yiisoft\Db\Expression\ExpressionInterface|integer can be used in concatenation? ( Ignorable by Annotation )

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

198
        $limitCondition = 'i>' . /** @scrutinizer ignore-type */ $start . (
Loading history...
199 54
            ($query->getLimit() === null || $query->getLimit() < 0) ? '' : ' and i<=' . ($start + $query->getLimit())
200
        );
201
202 54
        $key = $this->quoteValue($query->getARInstance()->keyPrefix());
203
204 54
        $loadColumnValues = '';
205
206 54
        foreach ($columns as $column => $alias) {
207 46
            $loadColumnValues .= "local $alias=redis.call('HGET',$key .. ':a:' .. pk, "
208 46
                . $this->quoteValue($column) . ")\n";
209
        }
210
211
        $getAllPks = <<<EOF
212 54
local allpks=redis.call('LRANGE',$key,0,-1)
213
EOF;
214 54
        if (!empty($query->getOrderBy())) {
215 6
            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...
216
                throw new NotSupportedException(
217
                    'orderBy by multiple columns is not currently supported by redis ActiveRecord.'
218
                );
219
            }
220
221 6
            $k = key($query->getOrderBy());
222 6
            $v = $query->getOrderBy()[$k];
223
224 6
            if (is_numeric($k)) {
225
                $orderColumn = $v;
226
                $orderType = 'ASC';
227
            } else {
228 6
                $orderColumn = $k;
229 6
                $orderType = $v === SORT_DESC ? 'DESC' : 'ASC';
230
            }
231
232
            $getAllPks = <<<EOF
233 6
local allpks=redis.pcall('SORT', $key, 'BY', $key .. ':a:*->' .. '$orderColumn', '$orderType')
234
if allpks['err'] then
235 6
    allpks=redis.pcall('SORT', $key, 'BY', $key .. ':a:*->' .. '$orderColumn', '$orderType', 'ALPHA')
236
end
237
EOF;
238
        }
239
240
        return <<<EOF
241 54
$getAllPks
242
local pks={}
243
local n=0
244
local v=nil
245
local i=0
246 54
local key=$key
247
for k,pk in ipairs(allpks) do
248 54
    $loadColumnValues
249 54
    if $condition then
250
      i=i+1
251 54
      if $limitCondition then
252 54
        $buildResult
253
      end
254
    end
255
end
256 54
return $return
257
EOF;
258
    }
259
260
    /**
261
     * Adds a column to the list of columns to retrieve and creates an alias.
262
     *
263
     * @param string $column the column name to add.
264
     * @param array $columns list of columns given by reference.
265
     *
266
     * @return string the alias generated for the column name.
267
     */
268 46
    private function addColumn(string $column, array &$columns = []): string
269
    {
270 46
        if (isset($columns[$column])) {
271 10
            return $columns[$column];
272
        }
273
274 46
        $name = 'c' . preg_replace("/[^a-z]+/i", "", $column) . count($columns);
275
276 46
        return $columns[$column] = $name;
277
    }
278
279
    /**
280
     * Quotes a string value for use in a query.
281
     *
282
     * Note that if the parameter is not a string or int, it will be returned without change.
283
     *
284
     * @param string|int $str string to be quoted.
285
     *
286
     * @return string|int the properly quoted string.
287
     */
288 54
    private function quoteValue($str)
289
    {
290 54
        if (!is_string($str) && !is_int($str)) {
0 ignored issues
show
introduced by
The condition is_int($str) is always true.
Loading history...
291
            return $str;
292
        }
293
294 54
        return "'" . addcslashes((string) $str, "\000\n\r\\\032\047") . "'";
295
    }
296
297
    /**
298
     * Parses the condition specification and generates the corresponding Lua expression.
299
     *
300
     * @param string|array $condition the condition specification. Please refer to {@see ActiveQuery::where()} on how
301
     * to specify a condition.
302
     * @param array $columns the list of columns and aliases to be used.
303
     *
304
     * @throws Exception if the condition is in bad format.
305
     * @throws NotSupportedException if the condition is not an array.
306
     *
307
     * @return string the generated SQL expression.
308
     */
309 47
    public function buildCondition($condition, array &$columns = []): string
310
    {
311 47
        static $builders = [
312
            'not' => 'buildNotCondition',
313
            'and' => 'buildAndCondition',
314
            'or' => 'buildAndCondition',
315
            'between' => 'buildBetweenCondition',
316
            'not between' => 'buildBetweenCondition',
317
            'in' => 'buildInCondition',
318
            'not in' => 'buildInCondition',
319
            'like' => 'buildLikeCondition',
320
            'not like' => 'buildLikeCondition',
321
            'or like' => 'buildLikeCondition',
322
            'or not like' => 'buildLikeCondition',
323
        ];
324
325 47
        if (!is_array($condition)) {
326
            throw new NotSupportedException('Where condition must be an array in redis ActiveRecord.');
327
        }
328
329
        /** operator format: operator, operand 1, operand 2, ... */
330 47
        if (isset($condition[0])) {
331 29
            $operator = strtolower($condition[0]);
332 29
            if (isset($builders[$operator])) {
333 29
                $method = $builders[$operator];
334 29
                array_shift($condition);
335
336 29
                return $this->$method($operator, $condition, $columns);
337
            }
338
339
            throw new Exception('Found unknown operator in query: ' . $operator);
340
        }
341
342
        /** hash format: 'column1' => 'value1', 'column2' => 'value2', ... */
343 32
        return $this->buildHashCondition($condition, $columns);
344
    }
345
346 32
    private function buildHashCondition($condition, &$columns)
347
    {
348 32
        $parts = [];
349
350 32
        foreach ($condition as $column => $value) {
351
            /** IN condition */
352 32
            if (is_array($value)) {
353 14
                $parts[] = $this->buildInCondition('in', [$column, $value], $columns);
354
            } else {
355 25
                if (is_bool($value)) {
356 1
                    $value = (int) $value;
357
                }
358
359 25
                if ($value === null) {
360 1
                    $parts[] = "redis.call('HEXISTS',key .. ':a:' .. pk, " . $this->quoteValue($column) . ")==0";
361 24
                } elseif ($value instanceof Expression) {
362
                    $column = $this->addColumn($column, $columns);
363
364
                    $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

364
                    $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...
365
                } else {
366 24
                    $column = $this->addColumn($column, $columns);
367 24
                    $value = $this->quoteValue((string) $value);
368
369 24
                    $parts[] = "$column==$value";
370
                }
371
            }
372
        }
373
374 32
        return count($parts) === 1 ? $parts[0] : '(' . implode(') and (', $parts) . ')';
375
    }
376
377 4
    private function buildNotCondition($operator, $operands, &$params)
378
    {
379 4
        if (count($operands) !== 1) {
380
            throw new InvalidParamException("Operator '$operator' requires exactly one operand.");
381
        }
382
383 4
        $operand = reset($operands);
384
385 4
        if (is_array($operand)) {
386 4
            $operand = $this->buildCondition($operand, $params);
387
        }
388
389 4
        return "$operator ($operand)";
390
    }
391
392 13
    private function buildAndCondition($operator, $operands, &$columns)
393
    {
394 13
        $parts = [];
395
396 13
        foreach ($operands as $operand) {
397 13
            if (is_array($operand)) {
398 13
                $operand = $this->buildCondition($operand, $columns);
399
            }
400 13
            if ($operand !== '') {
401 13
                $parts[] = $operand;
402
            }
403
        }
404
405 13
        if (!empty($parts)) {
406 13
            return '(' . implode(") $operator (", $parts) . ')';
407
        }
408
409
        return '';
410
    }
411
412 2
    private function buildBetweenCondition($operator, $operands, &$columns)
413
    {
414 2
        if (!isset($operands[0], $operands[1], $operands[2])) {
415
            throw new Exception("Operator '$operator' requires three operands.");
416
        }
417
418 2
        [$column, $value1, $value2] = $operands;
419
420 2
        $value1 = $this->quoteValue($value1);
421 2
        $value2 = $this->quoteValue($value2);
422 2
        $column = $this->addColumn($column, $columns);
423
424 2
        $condition = "$column >= $value1 and $column <= $value2";
425
426 2
        return $operator === 'not between' ? "not ($condition)" : $condition;
427
    }
428
429 30
    private function buildInCondition($operator, $operands, &$columns): string
430
    {
431 30
        if (!isset($operands[0], $operands[1])) {
432
            throw new Exception("Operator '$operator' requires two operands.");
433
        }
434
435 30
        [$column, $values] = $operands;
436
437 30
        $values = (array) $values;
438
439 30
        if (empty($values) || $column === []) {
440 3
            return $operator === 'in' ? 'false' : 'true';
441
        }
442
443 30
        if (is_array($column) && count($column) > 1) {
444
            return $this->buildCompositeInCondition($operator, $column, $values, $columns);
445
        }
446
447 30
        if (is_array($column)) {
448 18
            $column = reset($column);
449
        }
450
451 30
        $columnAlias = $this->addColumn((string) $column, $columns);
452 30
        $parts = [];
453
454 30
        foreach ($values as $value) {
455 30
            if (is_array($value)) {
456
                $value = $value[$column] ?? null;
457
            }
458
459 30
            if ($value === null) {
460
                $parts[] = "redis.call('HEXISTS',key .. ':a:' .. pk, " . $this->quoteValue($column) . ")==0";
461 30
            } elseif ($value instanceof Expression) {
462
                $parts[] = "$columnAlias==" . $value->getExpression();
463
            } else {
464 30
                $value = $this->quoteValue($value);
465 30
                $parts[] = "$columnAlias==$value";
466
            }
467
        }
468
469 30
        $operator = $operator === 'in' ? '' : 'not ';
470
471 30
        return "$operator(" . implode(' or ', $parts) . ')';
472
    }
473
474
    protected function buildCompositeInCondition($operator, $inColumns, $values, &$columns): string
475
    {
476
        $vss = [];
477
478
        foreach ($values as $value) {
479
            $vs = [];
480
            foreach ($inColumns as $column) {
481
                if (isset($value[$column])) {
482
                    $columnAlias = $this->addColumn($column, $columns);
483
                    $vs[] = "$columnAlias==" . $this->quoteValue($value[$column]);
484
                } else {
485
                    $vs[] = "redis.call('HEXISTS',key .. ':a:' .. pk, " . $this->quoteValue($column) . ")==0";
486
                }
487
            }
488
489
            $vss[] = '(' . implode(' and ', $vs) . ')';
490
        }
491
492
        $operator = $operator === 'in' ? '' : 'not ';
493
494
        return "$operator(" . implode(' or ', $vss) . ')';
495
    }
496
497
    private function buildLikeCondition($operator, $operands, &$columns)
498
    {
499
        throw new NotSupportedException('LIKE conditions are not suppoerted by redis ActiveRecord.');
500
    }
501
}
502