Passed
Pull Request — master (#806)
by Sergei
02:23
created

ExpressionBuilder::buildParamExpressions()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 19
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 20

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 4
eloc 8
c 1
b 0
f 0
nc 4
nop 2
dl 0
loc 19
ccs 0
cts 0
cp 0
crap 20
rs 10
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Db\Expression;
6
7
use Yiisoft\Db\Connection\ConnectionInterface;
8
use Yiisoft\Db\QueryBuilder\QueryBuilderInterface;
9
use Yiisoft\Db\Syntax\SqlParser;
10
11
use function array_merge;
12
use function count;
13
use function strlen;
14
use function substr;
15
use function substr_replace;
16
17
/**
18
 * It's used to build expressions for use in database queries.
19 333
 *
20
 * It provides a methods {@see build()} for creating various types of expressions, such as conditions, joins, and
21 333
 * ordering clauses.
22
 *
23 333
 * These expressions can be used with the query builder to build complex and customizable database queries
24
 * {@see Expression} class.
25
 *
26
 * @psalm-import-type ParamsType from ConnectionInterface
27
 */
28
class ExpressionBuilder implements ExpressionBuilderInterface
29
{
30
    public function __construct(private QueryBuilderInterface|null $queryBuilder = null)
31
    {
32
    }
33
34
    /**
35
     * Builds SQL statement from the given expression.
36
     *
37
     * @param Expression $expression The expression to be built.
38
     * @param array $params The parameters to be bound to the query.
39
     *
40
     * @psalm-param ParamsType $params
41
     *
42
     * @return string SQL statement.
43
     */
44
    public function build(Expression $expression, array &$params = []): string
45
    {
46
        $sql = $expression->__toString();
47
        $expressionParams = $expression->getParams();
48
49
        if (empty($expressionParams)) {
50
            return $sql;
51
        }
52
53
        if ($this->queryBuilder === null || isset($expressionParams[0])) {
54
            $params = array_merge($params, $expressionParams);
55
            return $sql;
56
        }
57
58
        $nonUniqueReplacements = $this->appendParams($expressionParams, $params);
59
        $expressionReplacements = $this->buildParamExpressions($expressionParams, $params);
60
61
        $replacements = $this->mergeReplacements($nonUniqueReplacements, $expressionReplacements);
62
63
        if (empty($replacements)) {
64
            return $sql;
65
        }
66
67
        return $this->replacePlaceholders($sql, $replacements);
68
    }
69
70
    /**
71
     * Appends parameters to the list of query parameters replacing non-unique parameters with unique ones.
72
     *
73
     * @param array $expressionParams Parameters to be appended.
74
     * @param array $params Parameters to be bound to the query.
75
     *
76
     * @psalm-param ParamsType $expressionParams
77
     * @psalm-param ParamsType $params
78
     *
79
     * @return string[] Replacements for non-unique parameters.
80
     */
81
    private function appendParams(array &$expressionParams, array &$params): array
82
    {
83
        $nonUniqueParams = [];
84
85
        /** @var non-empty-string $name */
86
        foreach ($expressionParams as $name => $value) {
87
            $paramName = $name[0] === ':' ? substr($name, 1) : $name;
88
89
            if (!isset($params[$paramName]) && !isset($params[":$paramName"])) {
90
                $params[$name] = $value;
91
                continue;
92
            }
93
94
            $nonUniqueParams[$name] = $value;
95
        }
96
97
        $replacements = [];
98
99
        /** @var non-empty-string $name */
100
        foreach ($nonUniqueParams as $name => $value) {
101
            $paramName = $name[0] === ':' ? substr($name, 1) : $name;
102
            $uniqueName = $this->getUniqueName($paramName, $params);
103
104
            $replacements[":$paramName"] = ":$uniqueName";
105
106
            if ($name[0] === ':') {
107
                $uniqueName = ":$uniqueName";
108
            }
109
110
            $params[$uniqueName] = $value;
111
            $expressionParams[$uniqueName] = $value;
112
            unset($expressionParams[$name]);
113
        }
114
115
        return $replacements;
116
    }
117
118
    /**
119
     * Build expression values of parameters.
120
     *
121
     * @param array $expressionParams Parameters from the expression.
122
     * @param array $params Parameters to be bound to the query.
123
     *
124
     * @psalm-param ParamsType $expressionParams
125
     * @psalm-param ParamsType $params
126
     *
127
     * @return string[] Replacements for parameters.
128
     */
129
    private function buildParamExpressions(array $expressionParams, array &$params): array
130
    {
131
        $replacements = [];
132
133
        /** @var non-empty-string $name */
134
        foreach ($expressionParams as $name => $value) {
135
            if (!$value instanceof ExpressionInterface) {
136
                continue;
137
            }
138
139
            $placeholder = $name[0] !== ':' ? ":$name" : $name;
140
            /** @psalm-suppress PossiblyNullReference */
141
            $replacements[$placeholder] = $this->queryBuilder->buildExpression($value, $params);
0 ignored issues
show
Bug introduced by
The method buildExpression() does not exist on null. ( Ignorable by Annotation )

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

141
            /** @scrutinizer ignore-call */ 
142
            $replacements[$placeholder] = $this->queryBuilder->buildExpression($value, $params);

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...
142
143
            /** @psalm-var ParamsType $params */
144
            unset($params[$name]);
145
        }
146
147
        return $replacements;
148
    }
149
150
    /**
151
     * Merges replacements for non-unique parameters with replacements for expression parameters.
152
     *
153
     * @param string[] $replacements Replacements for non-unique parameters.
154
     * @param string[] $expressionReplacements Replacements for expression parameters.
155
     *
156
     * @return string[] Merged replacements.
157
     */
158
    private function mergeReplacements(array $replacements, array $expressionReplacements): array
159
    {
160
        if (empty($replacements)) {
161
            return $expressionReplacements;
162
        }
163
164
        if (empty($expressionReplacements)) {
165
            return $replacements;
166
        }
167
168
        /** @var non-empty-string $value */
169
        foreach ($replacements as $name => $value) {
170
            if (isset($expressionReplacements[$value])) {
171
                $replacements[$name] = $expressionReplacements[$value];
172
                unset($expressionReplacements[$value]);
173
            }
174
        }
175
176
        return $replacements + $expressionReplacements;
177
    }
178
179
    /**
180
     * Returns a unique name for the parameter without colon at the beginning.
181
     *
182
     * @param string $name Name of the parameter without colon at the beginning.
183
     * @param array $params Parameters to be bound to the query.
184
     *
185
     * @psalm-param ParamsType $params
186
     *
187
     * @return string Unique name of the parameter with colon at the beginning.
188
     *
189
     * @psalm-return non-empty-string
190
     */
191
    private function getUniqueName(string $name, array $params): string
192
    {
193
        $uniqueName = $name . '_0';
194
195
        for ($i = 1; isset($params[$uniqueName]) || isset($params[":$uniqueName"]); ++$i) {
196
            $uniqueName = $name . '_' . $i;
197
        }
198
199
        return $uniqueName;
200
    }
201
202
    /**
203
     * Replaces placeholders with replacements in SQL statement.
204
     *
205
     * @param string $sql SQL statement where the placeholder should be replaced.
206
     * @param string[] $replacements Replacements for placeholders.
207
     *
208
     * @return string SQL statement with the replaced placeholders.
209
     */
210
    private function replacePlaceholders(string $sql, array $replacements): string
211
    {
212
        $parser = $this->createSqlParser($sql);
213
        $offset = 0;
214
215
        while (null !== $placeholder = $parser->getNextPlaceholder($position)) {
216
            if (isset($replacements[$placeholder])) {
217
                /** @var int $position */
218
                $sql = substr_replace($sql, $replacements[$placeholder], $position + $offset, strlen($placeholder));
219
220
                if (count($replacements) === 1) {
221
                    break;
222
                }
223
224
                $offset += strlen($replacements[$placeholder]) - strlen($placeholder);
225
                unset($replacements[$placeholder]);
226
            }
227
        }
228
229
        return $sql;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $sql could return the type array which is incompatible with the type-hinted return string. Consider adding an additional type-check to rule them out.
Loading history...
230
    }
231
232
    /**
233
     * Creates an instance of {@see SqlParser} for the given SQL statement.
234
     *
235
     * @param string $sql SQL statement to be parsed.
236
     *
237
     * @return SqlParser SQL parser instance.
238
     */
239
    protected function createSqlParser(string $sql): SqlParser
240
    {
241
        return new SqlParser($sql);
242
    }
243
}
244