SqlPatternDefinitionMacros::toSql()   B
last analyzed

Complexity

Conditions 6
Paths 7

Size

Total Lines 47
Code Lines 32

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 1 Features 0
Metric Value
cc 6
eloc 32
c 1
b 1
f 0
nc 7
nop 2
dl 0
loc 47
rs 8.7857
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 mixed ...$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
     * _Ivory design note: The single space added between each two fragments aspires to be more practical than a mere
119
     * concatenation, which would require the user to specify spaces where the next fragment immediately continued with
120
     * the query. After all, the method has ambitions to at least partly understand the user wants to compose an SQL
121
     * query from several parts, thus, it is legitimate the query is modified appropriately._
122
     *
123
     * @param string|SqlPattern $fragment
124
     * @param mixed ...$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(
164
                        "Invalid type of $ord fragment. Isn't it a misplaced parameter value?"
165
                    );
166
                }
167
            }
168
169
            // add to the overall pattern
170
            $curSqlTorso = $curFragment->getSqlTorso();
171
            $curPosParams = $curFragment->getPositionalPlaceholders();
172
            if (self::needsSpaceAsGlue($curFragment, $overallSqlTorso, $overallEndsWithPlaceholder)) {
173
                $overallSqlTorso .= ' ';
174
            }
175
            $sqlTorsoOffset = strlen($overallSqlTorso);
176
            $sqlTorsoLen = strlen($curSqlTorso);
177
            $overallSqlTorso .= $curSqlTorso;
178
            $overallEndsWithPlaceholder = false;
179
            foreach ($curPosParams as $plcHdr) {
180
                $overallPlcHdr = new SqlPatternPlaceholder(
181
                    $sqlTorsoOffset + $plcHdr->getOffset(),
182
                    count($overallPosPlaceholders),
183
                    $plcHdr->getTypeName(),
184
                    $plcHdr->isTypeNameQuoted(),
185
                    $plcHdr->getSchemaName(),
186
                    $plcHdr->isSchemaNameQuoted(),
187
                    $plcHdr->isLooseTypeMode()
188
                );
189
                $overallPosPlaceholders[] = $overallPlcHdr;
190
                $overallEndsWithPlaceholder = ($overallEndsWithPlaceholder || $plcHdr->getOffset() == $sqlTorsoLen);
191
            }
192
            foreach ($curFragment->getNamedPlaceholderMap() as $name => $occurrences) {
193
                if (!isset($overallNamedPlaceholderMap[$name])) {
194
                    $overallNamedPlaceholderMap[$name] = [];
195
                }
196
                foreach ($occurrences as $plcHdr) {
197
                    assert($plcHdr instanceof SqlPatternPlaceholder);
198
                    $overallPlcHdr = new SqlPatternPlaceholder(
199
                        $sqlTorsoOffset + $plcHdr->getOffset(),
200
                        $name,
201
                        $plcHdr->getTypeName(),
202
                        $plcHdr->isTypeNameQuoted(),
203
                        $plcHdr->getSchemaName(),
204
                        $plcHdr->isSchemaNameQuoted(),
205
                        $plcHdr->isLooseTypeMode()
206
                    );
207
                    $overallNamedPlaceholderMap[$name][] = $overallPlcHdr;
208
                    $overallEndsWithPlaceholder = ($overallEndsWithPlaceholder || $plcHdr->getOffset() == $sqlTorsoLen);
209
                }
210
            }
211
212
            // values of parameters
213
            $plcHdrCnt = count($curPosParams);
214
            $posParams = array_slice($fragmentsAndParamValues, $argsProcessed, $plcHdrCnt);
215
            if (count($posParams) == $plcHdrCnt) {
216
                $overallPosParams = array_merge($overallPosParams, $posParams);
217
            } else {
218
                $ord = StringUtils::englishOrd($curFragmentNum);
219
                throw new \InvalidArgumentException("Not enough positional parameters for the $ord fragment");
220
            }
221
222
            $curFragmentNum++;
223
            $argsProcessed += count($posParams);
224
225
            $curFragment =& $fragmentsAndParamValues[$argsProcessed];
226
            $argsProcessed++;
227
        } while (isset($curFragment));
228
229
        $overallPattern = new SqlPattern($overallSqlTorso, $overallPosPlaceholders, $overallNamedPlaceholderMap);
230
231
        $def = new static($overallPattern, $overallPosParams);
232
        $def->setParams($namedParamValues);
233
        return $def;
234
    }
235
236
    private static function needsSpaceAsGlue(
237
        SqlPattern $curFragment,
238
        string $overallSqlTorso,
239
        bool $overallEndsWithPlaceholder
240
    ): bool {
241
        /**
242
         * The glue is needed if the overall part ends with a non-space character or placeholder and, at the same time,
243
         * the current fragment starts with a non-space character or placeholder.
244
         */
245
246
        if (!$overallEndsWithPlaceholder && !preg_match('~[^ \t\r\n]$~uD', $overallSqlTorso)) {
247
            return false;
248
        }
249
250
        $curPosParams = $curFragment->getPositionalPlaceholders();
251
        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...
252
            return true;
253
        }
254
255
        $curNamedParams = $curFragment->getNamedPlaceholderMap();
256
        // OPT: Require SqlPattern::$namedPlaceholderMap to be sorted by offset of the first occurrence of the name.
257
        //      Then, take just the first item instead of iterating over all names.
258
        foreach ($curNamedParams as $occurrences) {
259
            assert($occurrences[0] instanceof SqlPatternPlaceholder);
260
            if ($occurrences[0]->getOffset() == 0) { // occurrences are sorted, so checking only the first is sufficient
261
                return true;
262
            }
263
        }
264
265
        $curSqlTorso = $curFragment->getSqlTorso();
266
        return (bool)preg_match('~^[^ \t\r\n]~u', $curSqlTorso);
267
    }
268
269
    public static function getReferencedSerializer(
270
        SqlPatternPlaceholder $placeholder,
271
        ITypeDictionary $typeDictionary
272
    ): IValueSerializer {
273
        $typeName = $placeholder->getTypeName();
274
        if (!$placeholder->isTypeNameQuoted()) {
275
            $typeName = mb_strtolower($typeName);
0 ignored issues
show
Bug introduced by
It seems like $typeName can also be of type null; however, parameter $string of mb_strtolower() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

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

275
            $typeName = mb_strtolower(/** @scrutinizer ignore-type */ $typeName);
Loading history...
276
            // OPT: SqlPatternPlaceholder might also store the lower-case name, which might be cached
277
        }
278
279
        $schemaName = $placeholder->getSchemaName();
280
        if ($schemaName !== null) {
281
            if (!$placeholder->isSchemaNameQuoted()) {
282
                $schemaName = mb_strtolower($schemaName);
283
                // OPT: SqlPatternPlaceholder might also store the lower-case name, which might be cached
284
            }
285
        } elseif ($placeholder->isTypeNameQuoted()) {
286
            $schemaName = false;
287
        }
288
289
        $serializer = null;
290
        if ($schemaName === null) {
291
            $serializer = $typeDictionary->getValueSerializer($typeName);
0 ignored issues
show
Bug introduced by
It seems like $typeName can also be of type null; however, parameter $name of Ivory\Type\ITypeDictionary::getValueSerializer() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

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

291
            $serializer = $typeDictionary->getValueSerializer(/** @scrutinizer ignore-type */ $typeName);
Loading history...
292
        }
293
        if ($serializer === null) {
294
            $serializer = $typeDictionary->requireTypeByName($typeName, $schemaName);
0 ignored issues
show
Bug introduced by
It seems like $typeName can also be of type null; however, parameter $typeName of Ivory\Type\ITypeDictionary::requireTypeByName() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

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

294
            $serializer = $typeDictionary->requireTypeByName(/** @scrutinizer ignore-type */ $typeName, $schemaName);
Loading history...
295
        }
296
297
        return $serializer;
298
    }
299
300
301
    final private function __construct(SqlPattern $sqlPattern, array $positionalParameters)
302
    {
303
        $this->sqlPattern = $sqlPattern;
304
        $this->params = $positionalParameters;
305
        $this->unsatisfiedParams = array_fill_keys(array_keys($sqlPattern->getNamedPlaceholderMap()), true);
306
    }
307
308
    public function setParam($nameOrPosition, $value)
309
    {
310
        if (isset($this->unsatisfiedParams[$nameOrPosition])) {
311
            unset($this->unsatisfiedParams[$nameOrPosition]);
312
        } elseif (!array_key_exists($nameOrPosition, $this->params)) {
313
            throw new \InvalidArgumentException("The SQL pattern does not have parameter '$nameOrPosition'");
314
        }
315
316
        $this->params[$nameOrPosition] = $value;
317
        return $this;
318
    }
319
320
    public function setParams(iterable $paramMap)
321
    {
322
        foreach ($paramMap as $nameOrPosition => $value) {
323
            $this->setParam($nameOrPosition, $value);
324
        }
325
        return $this;
326
    }
327
328
    public function getSqlPattern(): SqlPattern
329
    {
330
        return $this->sqlPattern;
331
    }
332
333
    public function getParams(): array
334
    {
335
        return $this->params;
336
    }
337
338
    public function toSql(ITypeDictionary $typeDictionary, array $namedParameterValues = []): string
339
    {
340
        $unsatisfiedParams = array_diff_key($this->unsatisfiedParams, $namedParameterValues);
341
342
        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...
343
            $names = array_keys($unsatisfiedParams);
344
            if (count($names) == 1) {
345
                $msg = sprintf('Value for parameter "%s" has not been set.', $names[0]);
346
            } else {
347
                $msg = sprintf(
348
                    'Values for parameters %s and "%s" have not been set.',
349
                    implode(', ', array_map(function ($s) { return "\"$s\""; }, array_slice($names, 0, -1))),
350
                    $names[count($names) - 1]
351
                );
352
            }
353
            throw new InvalidStateException($msg);
354
        }
355
356
        $gen = $this->sqlPattern->generateSql();
357
        while ($gen->valid()) {
358
            $placeholder = $gen->current();
359
            assert($placeholder instanceof SqlPatternPlaceholder);
360
            $nameOrPos = $placeholder->getNameOrPosition();
361
362
            if (array_key_exists($nameOrPos, $namedParameterValues)) {
363
                $value = $namedParameterValues[$nameOrPos];
364
            } else {
365
                assert(
366
                    array_key_exists($placeholder->getNameOrPosition(), $this->params),
367
                    new NoDataException("Value for parameter {$placeholder->getNameOrPosition()} not set.")
368
                );
369
                $value = $this->params[$nameOrPos];
370
            }
371
372
            if ($placeholder->getTypeName() !== null) {
373
                $serializer = static::getReferencedSerializer($placeholder, $typeDictionary);
374
            } else {
375
                $serializer = $typeDictionary->requireTypeByValue($value);
376
            }
377
            $serializedValue = $serializer->serializeValue($value, !$placeholder->isLooseTypeMode());
378
379
            $gen->send($serializedValue);
380
        }
381
382
        $sql = $gen->getReturn();
383
        assert(is_string($sql));
384
        return $sql;
385
    }
386
}
387