Passed
Push — master ( 60c378...33ffe1 )
by Ondřej
02:20
created

SqlPatternDefinitionMacros::setParams()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 6
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 3
nc 2
nop 1
dl 0
loc 6
rs 9.4285
c 0
b 0
f 0
1
<?php
2
declare(strict_types=1);
3
namespace Ivory\Query;
4
5
use Ivory\Exception\InvalidStateException;
6
use Ivory\Exception\NoDataException;
7
use Ivory\Ivory;
8
use Ivory\Lang\SqlPattern\SqlPattern;
9
use Ivory\Lang\SqlPattern\SqlPatternPlaceholder;
10
use Ivory\Type\ITypeDictionary;
11
use Ivory\Type\IValueSerializer;
12
use Ivory\Utils\StringUtils;
13
14
trait SqlPatternDefinitionMacros
15
{
16
    /** @var SqlPattern */
17
    private $sqlPattern;
18
    /** @var array map: parameter name or position => supplied value */
19
    private $params;
20
    /** @var bool[] map: name of parameter which has not been set any value yet => <tt>true</tt> value */
21
    private $unsatisfiedParams;
22
23
24
    /**
25
     * Creates an SQL definition from an SQL string.
26
     *
27
     * No parameter substitution is performed on the string - it is used as is.
28
     *
29
     * @param string $sql SQL string
30
     * @return static
31
     */
32
    public static function fromSql(string $sql): self
33
    {
34
        $sqlPattern = new SqlPattern($sql, [], []);
35
        return new static($sqlPattern, []);
36
    }
37
38
    /**
39
     * Creates a new definition from an SQL pattern.
40
     *
41
     * Values for all positional parameters required by the pattern must be given.
42
     *
43
     * Example:
44
     * <code>
45
     * // relation definition given by "SELECT * FROM person WHERE role = 4 AND email = '[email protected]'"
46
     * $relDef = SqlRelationDefinition::fromPattern(
47
     *     'SELECT * FROM person WHERE role = %i AND email = %s',
48
     *     4, '[email protected]'
49
     * );
50
     *
51
     * // command defined by "DELETE FROM mytable WHERE id < 100"
52
     * $cmd = SqlCommand::fromPattern(
53
     *     'DELETE FROM %ident WHERE id < %i',
54
     *     'mytable', 100
55
     * );
56
     * </code>
57
     *
58
     * Performance considerations: parsing the SQL pattern, if given as a string, is done by the parser obtained by
59
     * {@link \Ivory\Ivory::getSqlPatternParser()}. Depending on Ivory configuration, the parser will cache the results
60
     * and reuse them for the same pattern next time.
61
     *
62
     * @param string|SqlPattern $sqlPattern
63
     * @param array ...$positionalParameters
64
     * @return static
65
     * @throws \InvalidArgumentException when the number of provided positional parameters differs from the number of
66
     *                                     positional parameters required by the pattern
67
     */
68
    public static function fromPattern($sqlPattern, ...$positionalParameters): self
69
    {
70
        if (!$sqlPattern instanceof SqlPattern) {
71
            $parser = Ivory::getSqlPatternParser();
72
            $sqlPattern = $parser->parse($sqlPattern);
73
        }
74
75
        if (count($sqlPattern->getPositionalPlaceholders()) != count($positionalParameters)) {
76
            throw new \InvalidArgumentException(sprintf(
77
                'The SQL pattern requires %d positional parameters, %d given.',
78
                count($sqlPattern->getPositionalPlaceholders()),
79
                count($positionalParameters)
80
            ));
81
        }
82
83
        return new static($sqlPattern, $positionalParameters);
84
    }
85
86
    /**
87
     * Creates an SQL definition from one or more fragments, each with its own positional parameters.
88
     *
89
     * Each fragment must be immediately followed by values for all positional parameters it requires. Then, another
90
     * fragment may follow. As the very last argument, a map of values for named parameters may optionally be given (or
91
     * {@link setParams()} may be used to set them later).
92
     *
93
     * The fragments get concatenated to form the resulting SQL pattern. A single space is added between each two
94
     * fragments the former of which ends with a non-whitespace character and the latter of which starts with a
95
     * non-whitespace character.
96
     *
97
     * Named parameters are shared among fragments. In other words, if two fragments use the same named parameter,
98
     * specifying the parameter by {@link setParam()} will substitute the same value to both fragments.
99
     *
100
     * Example:
101
     * <code>
102
     * // relation definition given by "SELECT * FROM person WHERE role = 4 AND email = '[email protected]'"
103
     * $relDef = SqlRelationDefinition::fromFragments(
104
     *     'SELECT * FROM person WHERE role = %i', 4, 'AND email = %s', '[email protected]'
105
     * );
106
     *
107
     * // command defined by "DELETE FROM mytable WHERE id < 100"
108
     * $cmd = SqlCommand::fromFragments(
109
     *     'DELETE FROM %ident', 'mytable',
110
     *     'WHERE id < %i', 100
111
     * );
112
     * </code>
113
     *
114
     * Performance considerations: parsing the SQL pattern, if given as a string, is done by the parser obtained by
115
     * {@link \Ivory\Ivory::getSqlPatternParser()}. Depending on Ivory configuration, the parser will cache the results
116
     * and reuse them for the same pattern next time.
117
     *
118
     * @internal Ivory design note: The single space added between each two fragments aspires to be more practical than
119
     * a mere concatenation, which would require the user to specify spaces where the next fragment immediately
120
     * continued with the query. After all, the method has ambitions to at least partly understand the user wants to
121
     * compose an SQL query from several parts, thus, it is legitimate the query is modified appropriately.
122
     *
123
     * @param string|SqlPattern $fragment
124
     * @param array ...$fragmentsAndParamValues
125
     *                                  further fragments (each of which is either a <tt>string</tt> or an
126
     *                                    {@link SqlPattern} object) and values of their parameters;
127
     *                                  the very last argument may be a map of values for named parameters to set
128
     *                                    immediately
129
     * @return static
130
     * @throws \InvalidArgumentException when any fragment is not followed by the exact number of parameter values it
131
     *                                     requires
132
     */
133
    public static function fromFragments($fragment, ...$fragmentsAndParamValues): self
134
    {
135
        // OPT: consider caching the overall pattern, saving the most of the hard work
136
137
        $overallSqlTorso = '';
138
        $overallPosPlaceholders = [];
139
        $overallNamedPlaceholderMap = [];
140
        $overallPosParams = [];
141
142
        $namedParamValues = [];
143
144
        $curFragment = $fragment;
145
        $curFragmentNum = 1;
146
        $argsProcessed = 0;
147
        $overallEndsWithPlaceholder = false;
148
        do {
149
            // process the fragment
150
            if (!$curFragment instanceof SqlPattern) {
151
                if (is_string($curFragment)) {
152
                    $parser = Ivory::getSqlPatternParser();
153
                    $curFragment = $parser->parse($curFragment);
154
                } elseif (
155
                    is_iterable($curFragment) &&
156
                    $argsProcessed > 0 &&
157
                    !array_key_exists($argsProcessed, $fragmentsAndParamValues)
158
                ) {
159
                    $namedParamValues = $curFragment;
160
                    break;
161
                } else {
162
                    $ord = StringUtils::englishOrd($curFragmentNum);
163
                    throw new \InvalidArgumentException("Invalid type of $ord fragment. Isn't it a misplaced parameter value?");
164
                }
165
            }
166
167
            // add to the overall pattern
168
            $curSqlTorso = $curFragment->getSqlTorso();
169
            $curPosParams = $curFragment->getPositionalPlaceholders();
170
            if (self::needsSpaceAsGlue($curFragment, $overallSqlTorso, $overallEndsWithPlaceholder)) {
171
                $overallSqlTorso .= ' ';
172
            }
173
            $sqlTorsoOffset = strlen($overallSqlTorso);
174
            $sqlTorsoLen = strlen($curSqlTorso);
175
            $overallSqlTorso .= $curSqlTorso;
176
            $overallEndsWithPlaceholder = false;
177
            foreach ($curPosParams as $plcHdr) {
178
                $overallPlcHdr = new SqlPatternPlaceholder(
179
                    $sqlTorsoOffset + $plcHdr->getOffset(),
180
                    count($overallPosPlaceholders),
181
                    $plcHdr->getTypeName(),
182
                    $plcHdr->isTypeNameQuoted(),
183
                    $plcHdr->getSchemaName(),
184
                    $plcHdr->isSchemaNameQuoted()
185
                );
186
                $overallPosPlaceholders[] = $overallPlcHdr;
187
                $overallEndsWithPlaceholder = ($overallEndsWithPlaceholder || $plcHdr->getOffset() == $sqlTorsoLen);
188
            }
189
            foreach ($curFragment->getNamedPlaceholderMap() as $name => $occurrences) {
190
                /** @var SqlPatternPlaceholder[] $occurrences */
191
                if (!isset($overallNamedPlaceholderMap[$name])) {
192
                    $overallNamedPlaceholderMap[$name] = [];
193
                }
194
                foreach ($occurrences as $plcHdr) {
195
                    $overallPlcHdr = new SqlPatternPlaceholder(
196
                        $sqlTorsoOffset + $plcHdr->getOffset(),
197
                        $name,
198
                        $plcHdr->getTypeName(),
199
                        $plcHdr->isTypeNameQuoted(),
200
                        $plcHdr->getSchemaName(),
201
                        $plcHdr->isSchemaNameQuoted()
202
                    );
203
                    $overallNamedPlaceholderMap[$name][] = $overallPlcHdr;
204
                    $overallEndsWithPlaceholder = ($overallEndsWithPlaceholder || $plcHdr->getOffset() == $sqlTorsoLen);
205
                }
206
            }
207
208
            // values of parameters
209
            $plcHdrCnt = count($curPosParams);
210
            $posParams = array_slice($fragmentsAndParamValues, $argsProcessed, $plcHdrCnt);
211
            if (count($posParams) == $plcHdrCnt) {
212
                $overallPosParams = array_merge($overallPosParams, $posParams);
213
            } else {
214
                $ord = StringUtils::englishOrd($curFragmentNum);
215
                throw new \InvalidArgumentException("Not enough positional parameters for the $ord fragment");
216
            }
217
218
            $curFragmentNum++;
219
            $argsProcessed += count($posParams);
220
221
            $curFragment =& $fragmentsAndParamValues[$argsProcessed];
222
            $argsProcessed++;
223
        } while (isset($curFragment));
224
225
        $overallPattern = new SqlPattern($overallSqlTorso, $overallPosPlaceholders, $overallNamedPlaceholderMap);
226
227
        $def = new static($overallPattern, $overallPosParams);
228
        $def->setParams($namedParamValues);
229
        return $def;
230
    }
231
232
    private static function needsSpaceAsGlue(
233
        SqlPattern $curFragment,
234
        string $overallSqlTorso,
235
        bool $overallEndsWithPlaceholder
236
    ): bool {
237
        /**
238
         * The glue is needed if the overall part ends with a non-space character or placeholder and, at the same time,
239
         * the current fragment starts with a non-space character or placeholder.
240
         */
241
242
        if (!$overallEndsWithPlaceholder && !preg_match('~[^ \t\r\n]$~uD', $overallSqlTorso)) {
243
            return false;
244
        }
245
246
        $curPosParams = $curFragment->getPositionalPlaceholders();
247
        if ($curPosParams && $curPosParams[0]->getOffset() == 0) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $curPosParams of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
248
            return true;
249
        }
250
251
        $curNamedParams = $curFragment->getNamedPlaceholderMap();
252
        // OPT: Require SqlPattern::$namedPlaceholderMap to be sorted by offset of the first occurrence of the name.
253
        //      Then, take just the first item instead of iterating over all names.
254
        foreach ($curNamedParams as $name => $occurrences) {
255
            /** @var SqlPatternPlaceholder[] $occurrences */
256
            if ($occurrences[0]->getOffset() == 0) { // occurrences are sorted, so checking only the first is sufficient
257
                return true;
258
            }
259
        }
260
261
        $curSqlTorso = $curFragment->getSqlTorso();
262
        return (bool)preg_match('~^[^ \t\r\n]~u', $curSqlTorso);
263
    }
264
265
    public static function getReferencedSerializer(
266
        SqlPatternPlaceholder $placeholder,
267
        ITypeDictionary $typeDictionary
268
    ): IValueSerializer {
269
        $typeName = $placeholder->getTypeName();
270
        if (!$placeholder->isTypeNameQuoted()) {
271
            $typeName = mb_strtolower($typeName); // OPT: SqlPatternPlaceholder might also store the lower-case name, which might be cached
272
        }
273
274
        $schemaName = $placeholder->getSchemaName();
275
        if ($schemaName !== null) {
276
            if (!$placeholder->isSchemaNameQuoted()) {
277
                $schemaName = mb_strtolower($schemaName); // OPT: SqlPatternPlaceholder might also store the lower-case name, which might be cached
278
            }
279
        } elseif ($placeholder->isTypeNameQuoted()) {
280
            $schemaName = false;
281
        }
282
283
        $serializer = null;
284
        if ($schemaName === null) {
285
            $serializer = $typeDictionary->getValueSerializer($typeName);
286
        }
287
        if ($serializer === null) {
288
            $serializer = $typeDictionary->requireTypeByName($typeName, $schemaName);
289
        }
290
291
        return $serializer;
292
    }
293
294
295
    final private function __construct(SqlPattern $sqlPattern, array $positionalParameters)
296
    {
297
        $this->sqlPattern = $sqlPattern;
298
        $this->params = $positionalParameters;
299
        $this->unsatisfiedParams = array_fill_keys(array_keys($sqlPattern->getNamedPlaceholderMap()), true);
300
    }
301
302
    public function setParam($nameOrPosition, $value)
303
    {
304
        if (isset($this->unsatisfiedParams[$nameOrPosition])) {
305
            unset($this->unsatisfiedParams[$nameOrPosition]);
306
        } elseif (!array_key_exists($nameOrPosition, $this->params)) {
307
            throw new \InvalidArgumentException("The SQL pattern does not have parameter '$nameOrPosition'");
308
        }
309
310
        $this->params[$nameOrPosition] = $value;
311
        return $this;
312
    }
313
314
    public function setParams(iterable $paramMap)
315
    {
316
        foreach ($paramMap as $nameOrPosition => $value) {
317
            $this->setParam($nameOrPosition, $value);
318
        }
319
        return $this;
320
    }
321
322
    public function getSqlPattern(): SqlPattern
323
    {
324
        return $this->sqlPattern;
325
    }
326
327
    public function getParams(): array
328
    {
329
        return $this->params;
330
    }
331
332
    public function toSql(ITypeDictionary $typeDictionary, array $namedParameterValues = []): string
333
    {
334
        $unsatisfiedParams = array_diff_key($this->unsatisfiedParams, $namedParameterValues);
335
336
        if ($unsatisfiedParams) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $unsatisfiedParams of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
337
            $names = array_keys($unsatisfiedParams);
338
            if (count($names) == 1) {
339
                $msg = sprintf('Value for parameter "%s" has not been set.', $names[0]);
340
            } else {
341
                $msg = sprintf(
342
                    'Values for parameters %s and "%s" have not been set.',
343
                    implode(', ', array_map(function ($s) { return "\"$s\""; }, array_slice($names, 0, -1))),
344
                    $names[count($names) - 1]
345
                );
346
            }
347
            throw new InvalidStateException($msg);
348
        }
349
350
        $gen = $this->sqlPattern->generateSql();
351
        while ($gen->valid()) {
352
            /** @var SqlPatternPlaceholder $placeholder */
353
            $placeholder = $gen->current();
354
            $nameOrPos = $placeholder->getNameOrPosition();
355
356
            if (array_key_exists($nameOrPos, $namedParameterValues)) {
357
                $value = $namedParameterValues[$nameOrPos];
358
            } else {
359
                assert(
360
                    array_key_exists($placeholder->getNameOrPosition(), $this->params),
361
                    new NoDataException("Value for parameter {$placeholder->getNameOrPosition()} not set.")
362
                );
363
                $value = $this->params[$nameOrPos];
364
            }
365
366
            if ($placeholder->getTypeName() !== null) {
367
                $serializer = static::getReferencedSerializer($placeholder, $typeDictionary);
368
            } else {
369
                $serializer = $typeDictionary->requireTypeByValue($value);
370
            }
371
            $serializedValue = $serializer->serializeValue($value);
372
373
            $gen->send($serializedValue);
374
        }
375
376
        $sql = $gen->getReturn();
377
        assert(is_string($sql));
378
        return $sql;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $sql could return the type null which is incompatible with the type-hinted return string. Consider adding an additional type-check to rule them out.
Loading history...
379
    }
380
}
381