Passed
Push — master ( 0c19f1...8279ff )
by Ondřej
06:33
created

SqlPatternDefinitionMacros::needsSpaceAsGlue()   C

Complexity

Conditions 7
Paths 5

Size

Total Lines 32
Code Lines 16

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 32
rs 6.7272
c 0
b 0
f 0
cc 7
eloc 16
nc 5
nop 3
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\Utils\StringUtils;
12
13
trait SqlPatternDefinitionMacros
14
{
15
    /** @var SqlPattern */
16
    private $sqlPattern;
17
    /** @var array map: parameter name or position => supplied value */
18
    private $params;
19
    /** @var bool[] map: name of parameter which has not been set any value yet => <tt>true</tt> value */
20
    private $unsatisfiedParams;
21
22
23
    /**
24
     * Creates an SQL definition from an SQL string.
25
     *
26
     * No parameter substitution is performed on the string - it is used as is.
27
     *
28
     * @param string $sql SQL string
29
     * @return static
30
     */
31
    public static function fromSql(string $sql): self
32
    {
33
        $sqlPattern = new SqlPattern($sql, [], []);
34
        return new static($sqlPattern, []);
35
    }
36
37
    /**
38
     * Creates a new definition from an SQL pattern.
39
     *
40
     * Values for all positional parameters required by the pattern must be given.
41
     *
42
     * Example:
43
     * <code>
44
     * // relation definition given by "SELECT * FROM person WHERE role = 4 AND email = '[email protected]'"
45
     * $relDef = SqlRelationDefinition::fromPattern(
46
     *     'SELECT * FROM person WHERE role = %i AND email = %s',
47
     *     4, '[email protected]'
48
     * );
49
     *
50
     * // command defined by "DELETE FROM mytable WHERE id < 100"
51
     * $cmd = SqlCommand::fromPattern(
52
     *     'DELETE FROM %ident WHERE id < %i',
53
     *     'mytable', 100
54
     * );
55
     * </code>
56
     *
57
     * Performance considerations: parsing the SQL pattern, if given as a string, is done by the parser obtained by
58
     * {@link \Ivory\Ivory::getSqlPatternParser()}. Depending on Ivory configuration, the parser will cache the results
59
     * and reuse them for the same pattern next time.
60
     *
61
     * @param string|SqlPattern $sqlPattern
62
     * @param array ...$positionalParameters
63
     * @return static
64
     * @throws \InvalidArgumentException when the number of provided positional parameters differs from the number of
65
     *                                     positional parameters required by the pattern
66
     */
67
    public static function fromPattern($sqlPattern, ...$positionalParameters): self
68
    {
69
        if (!$sqlPattern instanceof SqlPattern) {
70
            $parser = Ivory::getSqlPatternParser();
71
            $sqlPattern = $parser->parse($sqlPattern);
72
        }
73
74
        if (count($sqlPattern->getPositionalPlaceholders()) != count($positionalParameters)) {
75
            throw new \InvalidArgumentException(sprintf(
76
                'The SQL pattern requires %d positional parameters, %d given.',
77
                count($sqlPattern->getPositionalPlaceholders()),
78
                count($positionalParameters)
79
            ));
80
        }
81
82
        return new static($sqlPattern, $positionalParameters);
83
    }
84
85
    /**
86
     * Creates an SQL definition from one or more fragments, each with its own positional parameters.
87
     *
88
     * Each fragment must be immediately followed by values for all positional parameters it requires. Then, another
89
     * fragment may follow. As the very last argument, a map of values for named parameters may optionally be given (or
90
     * {@link setParams()} may be used to set them later).
91
     *
92
     * The fragments get concatenated to form the resulting SQL pattern. A single space is added between each two
93
     * fragments the former of which ends with a non-whitespace character and the latter of which starts with a
94
     * non-whitespace character.
95
     *
96
     * Named parameters are shared among fragments. In other words, if two fragments use the same named parameter,
97
     * specifying the parameter by {@link setParam()} will substitute the same value to both fragments.
98
     *
99
     * Example:
100
     * <code>
101
     * // relation definition given by "SELECT * FROM person WHERE role = 4 AND email = '[email protected]'"
102
     * $relDef = SqlRelationDefinition::fromFragments(
103
     *     'SELECT * FROM person WHERE role = %i', 4, 'AND email = %s', '[email protected]'
104
     * );
105
     *
106
     * // command defined by "DELETE FROM mytable WHERE id < 100"
107
     * $cmd = SqlCommand::fromFragments(
108
     *     'DELETE FROM %ident', 'mytable',
109
     *     'WHERE id < %i', 100
110
     * );
111
     * </code>
112
     *
113
     * Performance considerations: parsing the SQL pattern, if given as a string, is done by the parser obtained by
114
     * {@link \Ivory\Ivory::getSqlPatternParser()}. Depending on Ivory configuration, the parser will cache the results
115
     * and reuse them for the same pattern next time.
116
     *
117
     * @internal Ivory design note: The single space added between each two fragments aspires to be more practical than
118
     * a mere concatenation, which would require the user to specify spaces where the next fragment immediately
119
     * continued with the query. After all, the method has ambitions to at least partly understand the user wants to
120
     * compose an SQL query from several parts, thus, it is legitimate the query is modified appropriately.
121
     *
122
     * @param string|SqlPattern $fragment
123
     * @param array ...$fragmentsAndParamValues
124
     *                                  further fragments (each of which is either a <tt>string</tt> or an
125
     *                                    {@link SqlPattern} object) and values of their parameters;
126
     *                                  the very last argument may be a map of values for named parameters to set
127
     *                                    immediately
128
     * @return static
129
     * @throws \InvalidArgumentException when any fragment is not followed by the exact number of parameter values it
130
     *                                     requires
131
     */
132
    public static function fromFragments($fragment, ...$fragmentsAndParamValues): self
133
    {
134
        // OPT: consider caching the overall pattern, saving the most of the hard work
135
136
        $overallSqlTorso = '';
137
        $overallPosPlaceholders = [];
138
        $overallNamedPlaceholderMap = [];
139
        $overallPosParams = [];
140
141
        $namedParamValues = [];
142
143
        $curFragment = $fragment;
144
        $curFragmentNum = 1;
145
        $argsProcessed = 0;
146
        $overallEndsWithPlaceholder = false;
147
        do {
148
            // process the fragment
149
            if (!$curFragment instanceof SqlPattern) {
150
                if (is_string($curFragment)) {
151
                    $parser = Ivory::getSqlPatternParser();
152
                    $curFragment = $parser->parse($curFragment);
153
                } elseif (
154
                    is_iterable($curFragment) &&
155
                    $argsProcessed > 0 &&
156
                    !array_key_exists($argsProcessed, $fragmentsAndParamValues)
157
                ) {
158
                    $namedParamValues = $curFragment;
159
                    break;
160
                } else {
161
                    $ord = StringUtils::englishOrd($curFragmentNum);
162
                    throw new \InvalidArgumentException("Invalid type of $ord fragment. Isn't it a misplaced parameter value?");
163
                }
164
            }
165
166
            // add to the overall pattern
167
            $curSqlTorso = $curFragment->getSqlTorso();
168
            $curPosParams = $curFragment->getPositionalPlaceholders();
169
            if (self::needsSpaceAsGlue($curFragment, $overallSqlTorso, $overallEndsWithPlaceholder)) {
170
                $overallSqlTorso .= ' ';
171
            }
172
            $sqlTorsoOffset = strlen($overallSqlTorso);
173
            $sqlTorsoLen = strlen($curSqlTorso);
174
            $overallSqlTorso .= $curSqlTorso;
175
            $overallEndsWithPlaceholder = false;
176 View Code Duplication
            foreach ($curPosParams as $plcHdr) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
177
                $overallPlcHdr = new SqlPatternPlaceholder(
178
                    $sqlTorsoOffset + $plcHdr->getOffset(),
179
                    count($overallPosPlaceholders),
180
                    $plcHdr->getTypeName(),
181
                    $plcHdr->isTypeNameQuoted(),
182
                    $plcHdr->getSchemaName(),
183
                    $plcHdr->isSchemaNameQuoted()
184
                );
185
                $overallPosPlaceholders[] = $overallPlcHdr;
186
                $overallEndsWithPlaceholder = ($overallEndsWithPlaceholder || $plcHdr->getOffset() == $sqlTorsoLen);
187
            }
188
            foreach ($curFragment->getNamedPlaceholderMap() as $name => $occurrences) {
189
                /** @var SqlPatternPlaceholder[] $occurrences */
190
                if (!isset($overallNamedPlaceholderMap[$name])) {
191
                    $overallNamedPlaceholderMap[$name] = [];
192
                }
193 View Code Duplication
                foreach ($occurrences as $plcHdr) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
194
                    $overallPlcHdr = new SqlPatternPlaceholder(
195
                        $sqlTorsoOffset + $plcHdr->getOffset(),
196
                        $name,
197
                        $plcHdr->getTypeName(),
198
                        $plcHdr->isTypeNameQuoted(),
199
                        $plcHdr->getSchemaName(),
200
                        $plcHdr->isSchemaNameQuoted()
201
                    );
202
                    $overallNamedPlaceholderMap[$name][] = $overallPlcHdr;
203
                    $overallEndsWithPlaceholder = ($overallEndsWithPlaceholder || $plcHdr->getOffset() == $sqlTorsoLen);
204
                }
205
            }
206
207
            // values of parameters
208
            $plcHdrCnt = count($curPosParams);
209
            $posParams = array_slice($fragmentsAndParamValues, $argsProcessed, $plcHdrCnt);
210
            if (count($posParams) == $plcHdrCnt) {
211
                $overallPosParams = array_merge($overallPosParams, $posParams);
212
            } else {
213
                $ord = StringUtils::englishOrd($curFragmentNum);
214
                throw new \InvalidArgumentException("Not enough positional parameters for the $ord fragment");
215
            }
216
217
            $curFragmentNum++;
218
            $argsProcessed += count($posParams);
219
220
            $curFragment =& $fragmentsAndParamValues[$argsProcessed];
221
            $argsProcessed++;
222
        } while (isset($curFragment));
223
224
        $overallPattern = new SqlPattern($overallSqlTorso, $overallPosPlaceholders, $overallNamedPlaceholderMap);
225
226
        $def = new static($overallPattern, $overallPosParams);
227
        $def->setParams($namedParamValues);
228
        return $def;
229
    }
230
231
    private static function needsSpaceAsGlue(
232
        SqlPattern $curFragment,
233
        string $overallSqlTorso,
234
        bool $overallEndsWithPlaceholder
235
    ): bool {
236
        /**
237
         * The glue is needed if the overall part ends with a non-space character or placeholder and, at the same time,
238
         * the current fragment starts with a non-space character or placeholder.
239
         */
240
241
        if (!$overallEndsWithPlaceholder && !preg_match('~[^ \t\r\n]$~uD', $overallSqlTorso)) {
242
            return false;
243
        }
244
245
        $curPosParams = $curFragment->getPositionalPlaceholders();
246
        if ($curPosParams && $curPosParams[0]->getOffset() == 0) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $curPosParams of type Ivory\Lang\SqlPattern\SqlPatternPlaceholder[] 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...
247
            return true;
248
        }
249
250
        $curNamedParams = $curFragment->getNamedPlaceholderMap();
251
        // OPT: Require SqlPattern::$namedPlaceholderMap to be sorted by offset of the first occurrence of the name.
252
        //      Then, take just the first item instead of iterating over all names.
253
        foreach ($curNamedParams as $name => $occurrences) {
254
            /** @var SqlPatternPlaceholder[] $occurrences */
255
            if ($occurrences[0]->getOffset() == 0) { // occurrences are sorted, so checking only the first is sufficient
256
                return true;
257
            }
258
        }
259
260
        $curSqlTorso = $curFragment->getSqlTorso();
261
        return (bool)preg_match('~^[^ \t\r\n]~u', $curSqlTorso);
262
    }
263
264
    final private function __construct(SqlPattern $sqlPattern, array $positionalParameters)
265
    {
266
        $this->sqlPattern = $sqlPattern;
267
        $this->params = $positionalParameters;
268
        $this->unsatisfiedParams = array_fill_keys(array_keys($sqlPattern->getNamedPlaceholderMap()), true);
269
    }
270
271
    public function setParam($nameOrPosition, $value)
272
    {
273
        if (isset($this->unsatisfiedParams[$nameOrPosition])) {
274
            unset($this->unsatisfiedParams[$nameOrPosition]);
275
        } elseif (!array_key_exists($nameOrPosition, $this->params)) {
276
            throw new \InvalidArgumentException("The SQL pattern does not have parameter '$nameOrPosition'");
277
        }
278
279
        $this->params[$nameOrPosition] = $value;
280
        return $this;
281
    }
282
283
    public function setParams(iterable $paramMap)
284
    {
285
        foreach ($paramMap as $nameOrPosition => $value) {
286
            $this->setParam($nameOrPosition, $value);
287
        }
288
        return $this;
289
    }
290
291
    public function getSqlPattern(): SqlPattern
292
    {
293
        return $this->sqlPattern;
294
    }
295
296
    public function getParams(): array
297
    {
298
        return $this->params;
299
    }
300
301
    public function toSql(ITypeDictionary $typeDictionary, array $namedParameterValues = []): string
302
    {
303
        $unsatisfiedParams = array_diff_key($this->unsatisfiedParams, $namedParameterValues);
304
305
        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...
306
            $names = array_keys($unsatisfiedParams);
307
            if (count($names) == 1) {
308
                $msg = sprintf('Value for parameter "%s" has not been set.', $names[0]);
309
            } else {
310
                $msg = sprintf(
311
                    'Values for parameters %s and "%s" have not been set.',
312
                    implode(', ', array_map(function ($s) { return "\"$s\""; }, array_slice($names, 0, -1))),
313
                    $names[count($names) - 1]
314
                );
315
            }
316
            throw new InvalidStateException($msg);
317
        }
318
319
        $gen = $this->sqlPattern->generateSql();
320
        while ($gen->valid()) {
321
            /** @var SqlPatternPlaceholder $placeholder */
322
            $placeholder = $gen->current();
323
            $nameOrPos = $placeholder->getNameOrPosition();
324
325
            if (array_key_exists($nameOrPos, $namedParameterValues)) {
326
                $value = $namedParameterValues[$nameOrPos];
327
            } else {
328
                assert(
329
                    array_key_exists($placeholder->getNameOrPosition(), $this->params),
330
                    new NoDataException("Value for parameter {$placeholder->getNameOrPosition()} not set.")
331
                );
332
                $value = $this->params[$nameOrPos];
333
            }
334
335
            if ($placeholder->getTypeName() !== null) {
336
                $typeName = $placeholder->getTypeName();
337
                if (!$placeholder->isTypeNameQuoted()) {
338
                    $typeName = mb_strtolower($typeName); // OPT: SqlPatternPlaceholder might also store the lower-case name, which might be cached
339
                }
340
                $schemaName = $placeholder->getSchemaName();
341
                if ($schemaName !== null) {
342
                    if (!$placeholder->isSchemaNameQuoted()) {
343
                        $schemaName = mb_strtolower($schemaName); // OPT: SqlPatternPlaceholder might also store the lower-case name, which might be cached
344
                    }
345
                } elseif ($placeholder->isTypeNameQuoted()) {
346
                    $schemaName = false;
347
                }
348
349
                $serializer = null;
350
                if ($schemaName === null) {
351
                    $serializer = $typeDictionary->getValueSerializer($typeName);
352
                }
353
                if ($serializer === null) {
354
                    $serializer = $typeDictionary->requireTypeByName($typeName, $schemaName);
355
                }
356
            } else {
357
                $serializer = $typeDictionary->requireTypeByValue($value);
358
            }
359
            $serializedValue = $serializer->serializeValue($value);
360
361
            $gen->send($serializedValue);
362
        }
363
364
        $sql = $gen->getReturn();
365
        assert(is_string($sql));
366
        return $sql;
367
    }
368
}
369