Issues (43)

src/Expression/AbstractExpressionBuilder.php (3 issues)

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 {@see build()} method 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 $queryBuilder)
32
    {
33
    }
34
35
    /**
36
     * Builds an SQL expression from the given expression object.
37
     *
38
     * This method is called by the query builder to build SQL expressions from {@see ExpressionInterface} objects.
39
     *
40
     * @param Expression $expression The expression to build.
41
     * @param array $params The parameters to be bound to the query.
42
     *
43
     * @psalm-param ParamsType $params
44
     *
45
     * @return string SQL expression.
46
     */
47
    public function build(ExpressionInterface $expression, array &$params = []): string
48
    {
49
        $sql = $expression->__toString();
0 ignored issues
show
The method __toString() does not exist on Yiisoft\Db\Expression\ExpressionInterface. It seems like you code against a sub-type of Yiisoft\Db\Expression\ExpressionInterface such as 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

49
        /** @scrutinizer ignore-call */ 
50
        $sql = $expression->__toString();
Loading history...
50
        $expressionParams = $expression->getParams();
0 ignored issues
show
The method getParams() does not exist on Yiisoft\Db\Expression\ExpressionInterface. It seems like you code against a sub-type of Yiisoft\Db\Expression\ExpressionInterface such as Yiisoft\Db\Expression\Expression or Yiisoft\Db\Query\QueryInterface. ( Ignorable by Annotation )

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

50
        /** @scrutinizer ignore-call */ 
51
        $expressionParams = $expression->getParams();
Loading history...
51
52
        if (empty($expressionParams)) {
53
            return $sql;
54
        }
55
56
        if (isset($expressionParams[0])) {
57
            $params = array_merge($params, $expressionParams);
58
            return $sql;
59
        }
60
61
        $nonUniqueReplacements = $this->appendParams($expressionParams, $params);
62
        $expressionReplacements = $this->buildParamExpressions($expressionParams, $params);
63
64
        $replacements = $this->mergeReplacements($nonUniqueReplacements, $expressionReplacements);
65
66
        if (empty($replacements)) {
67
            return $sql;
68
        }
69
70
        return $this->replacePlaceholders($sql, $replacements);
71
    }
72
73
    /**
74
     * Appends parameters to the list of query parameters replacing non-unique parameters with unique ones.
75
     *
76
     * @param array $expressionParams Parameters to be appended.
77
     * @param array $params Parameters to be bound to the query.
78
     *
79
     * @psalm-param ParamsType $expressionParams
80
     * @psalm-param ParamsType $params
81
     *
82
     * @return string[] Replacements for non-unique parameters.
83
     */
84
    private function appendParams(array &$expressionParams, array &$params): array
85
    {
86
        $nonUniqueParams = [];
87
88
        /** @var non-empty-string $name */
89
        foreach ($expressionParams as $name => $value) {
90
            $paramName = $name[0] === ':' ? substr($name, 1) : $name;
91
92
            if (!isset($params[$paramName]) && !isset($params[":$paramName"])) {
93
                $params[$name] = $value;
94
                continue;
95
            }
96
97
            $nonUniqueParams[$name] = $value;
98
        }
99
100
        $replacements = [];
101
102
        /** @var non-empty-string $name */
103
        foreach ($nonUniqueParams as $name => $value) {
104
            $paramName = $name[0] === ':' ? substr($name, 1) : $name;
105
            $uniqueName = $this->getUniqueName($paramName, $params);
106
107
            $replacements[":$paramName"] = ":$uniqueName";
108
109
            if ($name[0] === ':') {
110
                $uniqueName = ":$uniqueName";
111
            }
112
113
            $params[$uniqueName] = $value;
114
            $expressionParams[$uniqueName] = $value;
115
            unset($expressionParams[$name]);
116
        }
117
118
        return $replacements;
119
    }
120
121
    /**
122
     * Build expression values of parameters.
123
     *
124
     * @param array $expressionParams Parameters from the expression.
125
     * @param array $params Parameters to be bound to the query.
126
     *
127
     * @psalm-param ParamsType $expressionParams
128
     * @psalm-param ParamsType $params
129
     *
130
     * @return string[] Replacements for parameters.
131
     */
132
    private function buildParamExpressions(array $expressionParams, array &$params): array
133
    {
134
        $replacements = [];
135
136
        /** @var non-empty-string $name */
137
        foreach ($expressionParams as $name => $value) {
138
            if (!$value instanceof ExpressionInterface || $value instanceof Param) {
139
                continue;
140
            }
141
142
            $placeholder = $name[0] !== ':' ? ":$name" : $name;
143
            $replacements[$placeholder] = $this->queryBuilder->buildExpression($value, $params);
144
145
            /** @psalm-var ParamsType $params */
146
            unset($params[$name]);
147
        }
148
149
        return $replacements;
150
    }
151
152
    /**
153
     * Merges replacements for non-unique parameters with replacements for expression parameters.
154
     *
155
     * @param string[] $replacements Replacements for non-unique parameters.
156
     * @param string[] $expressionReplacements Replacements for expression parameters.
157
     *
158
     * @return string[] Merged replacements.
159
     */
160
    private function mergeReplacements(array $replacements, array $expressionReplacements): array
161
    {
162
        if (empty($replacements)) {
163
            return $expressionReplacements;
164
        }
165
166
        if (empty($expressionReplacements)) {
167
            return $replacements;
168
        }
169
170
        /** @var non-empty-string $value */
171
        foreach ($replacements as $name => $value) {
172
            if (isset($expressionReplacements[$value])) {
173
                $replacements[$name] = $expressionReplacements[$value];
174
                unset($expressionReplacements[$value]);
175
            }
176
        }
177
178
        return $replacements + $expressionReplacements;
179
    }
180
181
    /**
182
     * Returns a unique name for the parameter without colon at the beginning.
183
     *
184
     * @param string $name Name of the parameter without colon at the beginning.
185
     * @param array $params Parameters to be bound to the query.
186
     *
187
     * @psalm-param ParamsType $params
188
     *
189
     * @return string Unique name of the parameter with colon at the beginning.
190
     *
191
     * @psalm-return non-empty-string
192
     */
193
    private function getUniqueName(string $name, array $params): string
194
    {
195
        $uniqueName = $name . '_0';
196
197
        for ($i = 1; isset($params[$uniqueName]) || isset($params[":$uniqueName"]); ++$i) {
198
            $uniqueName = $name . '_' . $i;
199
        }
200
201
        return $uniqueName;
202
    }
203
204
    /**
205
     * Replaces placeholders with replacements in a SQL expression.
206
     *
207
     * @param string $sql SQL expression where the placeholder should be replaced.
208
     * @param string[] $replacements Replacements for placeholders.
209
     *
210
     * @return string SQL expression with replaced placeholders.
211
     */
212
    private function replacePlaceholders(string $sql, array $replacements): string
213
    {
214
        $parser = $this->createSqlParser($sql);
215
        $offset = 0;
216
217
        while (null !== $placeholder = $parser->getNextPlaceholder($position)) {
218
            if (isset($replacements[$placeholder])) {
219
                /** @var int $position */
220
                $sql = substr_replace($sql, $replacements[$placeholder], $position + $offset, strlen($placeholder));
221
222
                if (count($replacements) === 1) {
223
                    break;
224
                }
225
226
                $offset += strlen($replacements[$placeholder]) - strlen($placeholder);
227
                unset($replacements[$placeholder]);
228
            }
229
        }
230
231
        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...
232
    }
233
234
    /**
235
     * Creates an instance of {@see AbstractSqlParser} for the given SQL expression.
236
     *
237
     * @param string $sql SQL expression to be parsed.
238
     *
239
     * @return AbstractSqlParser SQL parser instance.
240
     */
241
    abstract protected function createSqlParser(string $sql): AbstractSqlParser;
242
}
243