Passed
Pull Request — master (#806)
by Sergei
03:13
created

AbstractExpressionBuilder::buildParamExpressions()   A

Complexity

Conditions 5
Paths 4

Size

Total Lines 19
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

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

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