Passed
Push — master ( b2bdac...3a7f22 )
by Ondřej
03:22
created

SqlPatternDefinitionMacros::fromPattern()   A

Complexity

Conditions 3
Paths 4

Size

Total Lines 17
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 17
c 0
b 0
f 0
rs 9.4285
cc 3
eloc 10
nc 4
nop 2
1
<?php
2
namespace Ivory\Query;
3
4
use Ivory\Exception\InvalidStateException;
5
use Ivory\Exception\NoDataException;
6
use Ivory\Exception\UndefinedTypeException;
7
use Ivory\Lang\SqlPattern\SqlPattern;
8
use Ivory\Lang\SqlPattern\SqlPatternPlaceholder;
9
use Ivory\Type\ITypeDictionary;
10
use Ivory\Utils\StringUtils;
11
12
trait SqlPatternDefinitionMacros
13
{
14
    /** @var SqlPattern */
15
    private $sqlPattern;
16
    /** @var array map: parameter name or position => supplied value */
17
    private $params;
18
    /** @var bool[] map: name of parameter which has not been set any value yet => <tt>true</tt> value */
19
    private $unsatisfiedParams;
20
21
22
    /**
23
     * Creates an SQL definition from an SQL string.
24
     *
25
     * No parameter substitution is performed on the string - it is used as is.
26
     *
27
     * @param string $sql SQL string
28
     * @return static
29
     */
30
    public static function fromSql(string $sql): self
31
    {
32
        $sqlPattern = new SqlPattern($sql, [], []);
33
        return new static($sqlPattern, []);
34
    }
35
36
    /**
37
     * Creates a new definition from an SQL pattern.
38
     *
39
     * Values for all positional parameters required by the pattern must be given.
40
     *
41
     * Example:
42
     * <code>
43
     * // relation definition given by "SELECT * FROM person WHERE role = 4 AND email = '[email protected]'"
44
     * $relDef = SqlRelationDefinition::fromPattern(
45
     *     'SELECT * FROM person WHERE role = %i AND email = %s',
46
     *     4, '[email protected]'
47
     * );
48
     *
49
     * // command defined by "DELETE FROM mytable WHERE id < 100"
50
     * $cmd = SqlCommand::fromPattern(
51
     *     'DELETE FROM %ident WHERE id < %i',
52
     *     'mytable', 100
53
     * );
54
     * </code>
55
     *
56
     * Performance considerations: parsing the SQL pattern, if given as a string, is done by the parser obtained by
57
     * {@link \Ivory\Ivory::getSqlPatternParser()}. Depending on Ivory configuration, the parser will cache the results
58
     * and reuse them for the same pattern next time.
59
     *
60
     * @param string|SqlPattern $sqlPattern
61
     * @param array ...$positionalParameters
62
     * @return static
63
     * @throws \InvalidArgumentException when the number of provided positional parameters differs from the number of
64
     *                                     positional parameters required by the pattern
65
     */
66
    public static function fromPattern($sqlPattern, ...$positionalParameters): self
67
    {
68
        if (!$sqlPattern instanceof SqlPattern) {
69
            $parser = \Ivory\Ivory::getSqlPatternParser();
70
            $sqlPattern = $parser->parse($sqlPattern);
71
        }
72
73
        if (count($sqlPattern->getPositionalPlaceholders()) != count($positionalParameters)) {
74
            throw new \InvalidArgumentException(sprintf(
75
                'The SQL pattern requires %d positional parameters, %d given.',
76
                count($sqlPattern->getPositionalPlaceholders()),
77
                count($positionalParameters)
78
            ));
79
        }
80
81
        return new static($sqlPattern, $positionalParameters);
82
    }
83
84
    /**
85
     * Creates an SQL definition from one or more fragments, each with its own positional parameters.
86
     *
87
     * Each fragment must be immediately followed by values for all positional parameters it requires. Then, another
88
     * fragment may follow. As the very last argument, a map of values for named parameters may optionally be given (or
89
     * {@link setParams()} may be used to set them later).
90
     *
91
     * The fragments get concatenated to form the resulting SQL pattern. A single space is added between each two
92
     * fragments the former of which ends with a non-whitespace character and the latter of which starts with a
93
     * non-whitespace character.
94
     *
95
     * Named parameters are shared among fragments. In other words, if two fragments use the same named parameter,
96
     * specifying the parameter by {@link setParam()} will substitute the same value to both fragments.
97
     *
98
     * Example:
99
     * <code>
100
     * // relation definition given by "SELECT * FROM person WHERE role = 4 AND email = '[email protected]'"
101
     * $relDef = SqlRelationDefinition::fromFragments(
102
     *     'SELECT * FROM person WHERE role = %i', 4, 'AND email = %s', '[email protected]'
103
     * );
104
     *
105
     * // command defined by "DELETE FROM mytable WHERE id < 100"
106
     * $cmd = SqlCommand::fromFragments(
107
     *     'DELETE FROM %ident', 'mytable',
108
     *     'WHERE id < %i', 100
109
     * );
110
     * </code>
111
     *
112
     * Performance considerations: parsing the SQL pattern, if given as a string, is done by the parser obtained by
113
     * {@link \Ivory\Ivory::getSqlPatternParser()}. Depending on Ivory configuration, the parser will cache the results
114
     * and reuse them for the same pattern next time.
115
     *
116
     * @internal Ivory design note: The single space added between each two fragments aspires to be more practical than
117
     * a mere concatenation, which would require the user to specify spaces where the next fragment immediately
118
     * continued with the query. After all, the method has ambitions to at least partly understand the user wants to
119
     * compose an SQL query from several parts, thus, it is legitimate the query is modified appropriately.
120
     *
121
     * @param string|SqlPattern $fragment
122
     * @param array ...$fragmentsAndPositionalParams
123
     *                                  further fragments (each of which is either a <tt>string</tt> or an
124
     *                                    {@link SqlPattern} object) and values of their parameters;
125
     *                                  the very last argument may be a map of values for named parameters to set
126
     *                                    immediately
127
     *
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, ...$fragmentsAndPositionalParams): self
133
    {
134
        $overallSqlTorso = '';
135
        $overallPosPlaceholders = [];
136
        $overallNamedPlaceholderMap = [];
137
        $overallPosParams = [];
138
139
        $namedParamValues = [];
140
141
        $curFragment = $fragment;
142
        $curFragmentNum = 1;
143
        $argsProcessed = 0;
144
        $overallEndsWithPlaceholder = false;
145
        do {
146
            // process the fragment
147
            if (!$curFragment instanceof SqlPattern) {
148
                if (is_string($curFragment)) {
149
                    $parser = \Ivory\Ivory::getSqlPatternParser();
150
                    $curFragment = $parser->parse($curFragment);
151
                } elseif (
152
                    (is_array($curFragment) || $curFragment instanceof \Traversable) && // PHP 7.1: is_iterable()
153
                    $argsProcessed > 0 &&
154
                    !array_key_exists($argsProcessed, $fragmentsAndPositionalParams)
155
                ) {
156
                    $namedParamValues = $curFragment;
157
                    break;
158
                } else {
159
                    $ord = StringUtils::englishOrd($curFragmentNum);
160
                    throw new \InvalidArgumentException("Invalid type of $ord fragment. Isn't it a misplaced parameter value?");
161
                }
162
            }
163
164
            // add to the overall pattern
165
            $curSqlTorso = $curFragment->getSqlTorso();
166
            $curPosParams = $curFragment->getPositionalPlaceholders();
167
            $curNamedParams = $curFragment->getNamedPlaceholderMap();
168
            $fragmentStartsWithPlaceholder = (
169
                ($curPosParams ? $curPosParams[0]->getOffset() == 0 : false)
170
                ||
171
                ($curNamedParams ? current($curNamedParams)[0]->getOffset() == 0 : false)
172
            );
173
            if (($overallEndsWithPlaceholder || preg_match('~[^ \t\r\n]$~uD', $overallSqlTorso)) &&
174
                ($fragmentStartsWithPlaceholder || preg_match('~^[^ \t\r\n]~u', $curSqlTorso)))
175
            {
176
                $overallSqlTorso .= ' ';
177
            }
178
            $sqlTorsoOffset = strlen($overallSqlTorso);
179
            $sqlTorsoLen = strlen($curSqlTorso);
180
            $overallSqlTorso .= $curSqlTorso;
181
            $overallEndsWithPlaceholder = false;
182 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...
183
                $overallPlcHdr = new SqlPatternPlaceholder(
184
                    $sqlTorsoOffset + $plcHdr->getOffset(),
185
                    count($overallPosPlaceholders),
186
                    $plcHdr->getTypeName(),
187
                    $plcHdr->isTypeNameQuoted(),
188
                    $plcHdr->getSchemaName(),
189
                    $plcHdr->isSchemaNameQuoted()
190
                );
191
                $overallPosPlaceholders[] = $overallPlcHdr;
192
                $overallEndsWithPlaceholder = ($overallEndsWithPlaceholder || $plcHdr->getOffset() == $sqlTorsoLen);
193
            }
194
            foreach ($curFragment->getNamedPlaceholderMap() as $name => $occurrences) {
195
                /** @var SqlPatternPlaceholder[] $occurrences */
196
                if (!isset($overallNamedPlaceholderMap[$name])) {
197
                    $overallNamedPlaceholderMap[$name] = [];
198
                }
199 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...
200
                    $overallPlcHdr = new SqlPatternPlaceholder(
201
                        $sqlTorsoOffset + $plcHdr->getOffset(),
202
                        $name,
203
                        $plcHdr->getTypeName(),
204
                        $plcHdr->isTypeNameQuoted(),
205
                        $plcHdr->getSchemaName(),
206
                        $plcHdr->isSchemaNameQuoted()
207
                    );
208
                    $overallNamedPlaceholderMap[$name][] = $overallPlcHdr;
209
                    $overallEndsWithPlaceholder = ($overallEndsWithPlaceholder || $plcHdr->getOffset() == $sqlTorsoLen);
210
                }
211
            }
212
213
            // values of parameters
214
            $plcHdrCnt = count($curPosParams);
215
            $posParams = array_slice($fragmentsAndPositionalParams, $argsProcessed, $plcHdrCnt);
216
            if (count($posParams) == $plcHdrCnt) {
217
                $overallPosParams = array_merge($overallPosParams, $posParams);
218
            } else {
219
                $ord = StringUtils::englishOrd($curFragmentNum);
220
                throw new \InvalidArgumentException("Not enough positional parameters for the $ord fragment");
221
            }
222
223
            $curFragmentNum++;
224
            $argsProcessed += count($posParams);
225
226
            $curFragment =& $fragmentsAndPositionalParams[$argsProcessed];
227
            $argsProcessed++;
228
        } while (isset($curFragment));
229
230
        $overallPattern = new SqlPattern($overallSqlTorso, $overallPosPlaceholders, $overallNamedPlaceholderMap);
231
232
        $def = new static($overallPattern, $overallPosParams);
233
        $def->setParams($namedParamValues);
234
        return $def;
235
    }
236
237
    final private function __construct(SqlPattern $sqlPattern, array $positionalParameters)
238
    {
239
        $this->sqlPattern = $sqlPattern;
240
        $this->params = $positionalParameters;
241
        $this->unsatisfiedParams = array_fill_keys(array_keys($sqlPattern->getNamedPlaceholderMap()), true);
242
    }
243
244
    public function setParam($nameOrPosition, $value)
245
    {
246
        if (isset($this->unsatisfiedParams[$nameOrPosition])) {
247
            unset($this->unsatisfiedParams[$nameOrPosition]);
248
        } elseif (!array_key_exists($nameOrPosition, $this->params)) {
249
            throw new \InvalidArgumentException("The SQL pattern does not have parameter '$nameOrPosition'");
250
        }
251
252
        $this->params[$nameOrPosition] = $value;
253
        return $this;
254
    }
255
256
    public function setParams($paramMap)
257
    {
258
        foreach ($paramMap as $nameOrPosition => $value) {
259
            $this->setParam($nameOrPosition, $value);
260
        }
261
        return $this;
262
    }
263
264
    public function getSqlPattern(): SqlPattern
265
    {
266
        return $this->sqlPattern;
267
    }
268
269
    public function getParams(): array
270
    {
271
        return $this->params;
272
    }
273
274
    /**
275
     * @param ITypeDictionary $typeDictionary
276
     * @return string
277
     * @throws InvalidStateException when values for one or more named parameters has not been set
278
     * @throws UndefinedTypeException when some of the types used in the pattern are not defined
279
     */
280
    public function toSql(ITypeDictionary $typeDictionary): string
281
    {
282
        if ($this->unsatisfiedParams) {
283
            $names = array_keys($this->unsatisfiedParams);
284
            if (count($names) == 1) {
285
                $msg = sprintf('Value for parameter "%s" has not been set.', $names[0]);
286
            } else {
287
                $msg = sprintf(
288
                    'Values for parameters %s and "%s" have not been set.',
289
                    array_map(function ($s) { return "\"$s\""; }, array_slice($names, 0, -1))
290
                );
291
            }
292
            throw new InvalidStateException($msg);
293
        }
294
295
        $gen = $this->sqlPattern->generateSql();
296
        while ($gen->valid()) {
297
            /** @var SqlPatternPlaceholder $placeholder */
298
            $placeholder = $gen->current();
299
            assert(
300
                array_key_exists($placeholder->getNameOrPosition(), $this->params),
301
                new NoDataException("Value for parameter {$placeholder->getNameOrPosition()} not set.")
302
            );
303
304
            $value = $this->params[$placeholder->getNameOrPosition()];
305
306
            if ($placeholder->getTypeName() !== null) {
307
                $typeName = $placeholder->getTypeName();
308
                if (!$placeholder->isTypeNameQuoted()) {
309
                    $typeName = mb_strtolower($typeName); // OPT: SqlPatternPlaceholder might also store the lower-case name, which might be cached
310
                }
311
                $schemaName = $placeholder->getSchemaName();
312
                if ($schemaName !== null) {
313
                    if (!$placeholder->isSchemaNameQuoted()) {
314
                        $schemaName = mb_strtolower($schemaName); // OPT: SqlPatternPlaceholder might also store the lower-case name, which might be cached
315
                    }
316
                } elseif ($placeholder->isTypeNameQuoted()) {
317
                    $schemaName = false;
318
                }
319
320
                $serializer = null;
321
                if ($schemaName === null) {
322
                    $serializer = $typeDictionary->getValueSerializer($typeName);
323
                }
324
                if ($serializer === null) {
325
                    $serializer = $typeDictionary->requireTypeByName($typeName, $schemaName);
326
                }
327
            } else {
328
                $serializer = $typeDictionary->requireTypeByValue($value);
329
            }
330
            $serializedValue = $serializer->serializeValue($value);
331
332
            $gen->send($serializedValue);
333
        }
334
335
        return $gen->getReturn();
336
    }
337
}
338