Completed
Push — master ( bd03b7...2e6f46 )
by Ondřej
03:14
created

SqlPatternRecipeMacros::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 SqlPatternRecipeMacros
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 recipe 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 recipe from an SQL pattern.
38
     *
39
     * Values for all positional parameters required by the pattern must be given.
40
     *
41
     * Example:
42
     * <pre>
43
     * <?php
44
     * $recipe = new SqlRecipe(
45
     *   'SELECT *, %s:status FROM person WHERE role = %d AND email = %s',
46
     *   4, '[email protected]'
47
     * );
48
     * // results in "SELECT * FROM person WHERE role = 4 AND email = '[email protected]'"
49
     * </pre>
50
     *
51
     * Performance considerations: parsing the SQL pattern, if given as a string, is done by the parser obtained by
52
     * {@link \Ivory\Ivory::getSqlPatternParser()}. Depending on Ivory configuration, the parser will cache the results
53
     * and reuse them for the same pattern next time.
54
     *
55
     * @param string|SqlPattern $sqlPattern
56
     * @param array ...$positionalParameters
57
     * @return static
58
     * @throws \InvalidArgumentException when the number of provided positional parameters differs from the number of
59
     *                                     positional parameters required by the pattern
60
     */
61
    public static function fromPattern($sqlPattern, ...$positionalParameters) : self
62
    {
63
        if (!$sqlPattern instanceof SqlPattern) {
64
            $parser = \Ivory\Ivory::getSqlPatternParser();
65
            $sqlPattern = $parser->parse($sqlPattern);
66
        }
67
68
        if (count($sqlPattern->getPositionalPlaceholders()) != count($positionalParameters)) {
69
            throw new \InvalidArgumentException(sprintf(
70
                'The SQL pattern requires %d positional parameters, %d given.',
71
                count($sqlPattern->getPositionalPlaceholders()),
72
                count($positionalParameters)
73
            ));
74
        }
75
76
        return new static($sqlPattern, $positionalParameters);
77
    }
78
79
    /**
80
     * Creates an SQL recipe from one or more fragments, each with its own positional parameters.
81
     *
82
     * Each fragment must be immediately followed by values for all positional parameters it requires. Then, another
83
     * fragment may follow. As the very last argument, a map of values for named parameters may optionally be given (or
84
     * {@link SqlRecipe::setParams()} may be used to set them later).
85
     *
86
     * The fragments get concatenated to form the resulting SQL pattern. A single space is added between each two
87
     * fragments the latter of which does not start with whitespace.
88
     *
89
     * Named parameters are shared among fragments. In other words, if two fragments use the same named parameter,
90
     * specifying the parameter by {@link setParam()} will substitute the same value to both fragments.
91
     *
92
     * Example:
93
     * <pre>
94
     * <?php
95
     * $recipe = SqlRecipe::fromFragments(
96
     *   'SELECT * FROM person WHERE role = %d', 4, 'AND email = %s', '[email protected]'
97
     * );
98
     * // results in "SELECT * FROM person WHERE role = 4 AND email = '[email protected]'"
99
     * </pre>
100
     *
101
     * Performance considerations: parsing the SQL pattern, if given as a string, is done by the parser obtained by
102
     * {@link \Ivory\Ivory::getSqlPatternParser()}. Depending on Ivory configuration, the parser will cache the results
103
     * and reuse them for the same pattern next time.
104
     *
105
     * @internal Ivory design note: The single space added between each two fragments aspires to be more practical than
106
     * a mere concatenation, which would require the user to specify spaces where the next fragment immediately
107
     * continued with the query.
108
     *
109
     * @param string|SqlPattern $fragment
110
     * @param array ...$fragmentsAndPositionalParams
111
     *                                  further fragments (each of which is either a <tt>string</tt> or an
112
     *                                    {@link SqlPattern} object) and values of their parameters;
113
     *                                  the very last argument may be a map of values for named parameters to set
114
     *                                    immediately
115
     *
116
     * @return static
117
     * @throws \InvalidArgumentException when any fragment is not followed by the exact number of parameter values it
118
     *                                     requires
119
     */
120
    public static function fromFragments($fragment, ...$fragmentsAndPositionalParams) : self
121
    {
122
        $overallSqlTorso = '';
123
        $overallPosPlaceholders = [];
124
        $overallNamedPlaceholderMap = [];
125
        $overallPosParams = [];
126
127
        $namedParamValues = [];
128
129
        $curFragment = $fragment;
130
        $curFragmentNum = 1;
131
        $argsProcessed = 0;
132
        do {
133
            // process the fragment
134
            if (!$curFragment instanceof SqlPattern) {
135
                if (is_string($curFragment)) {
136
                    $parser = \Ivory\Ivory::getSqlPatternParser();
137
                    $curFragment = $parser->parse($curFragment);
138
                }
139
                elseif ((is_array($curFragment) || $curFragment instanceof \Traversable) && // PHP 7.1: is_iterable()
1 ignored issue
show
Unused Code Comprehensibility introduced by
45% of this comment could be valid code. Did you maybe forget this after debugging?

Sometimes obsolete code just ends up commented out instead of removed. In this case it is better to remove the code once you have checked you do not need it.

The code might also have been commented out for debugging purposes. In this case it is vital that someone uncomments it again or your project may behave in very unexpected ways in production.

This check looks for comments that seem to be mostly valid code and reports them.

Loading history...
140
                    $argsProcessed > 0 && !array_key_exists($argsProcessed, $fragmentsAndPositionalParams))
141
                {
142
                    $namedParamValues = $curFragment;
143
                    break;
144
                }
145
                else {
146
                    $ord = StringUtils::englishOrd($curFragmentNum);
147
                    throw new \InvalidArgumentException("Invalid type of $ord fragment. Isn't it a misplaced parameter value?");
148
                }
149
            }
150
151
            // add to the overall pattern
152
            if ($argsProcessed > 0 && !preg_match('~^\s~', $curFragment->getSqlTorso())) {
153
                $overallSqlTorso .= ' ';
154
            }
155
            $sqlTorsoOffset = strlen($overallSqlTorso);
156
            $overallSqlTorso .= $curFragment->getSqlTorso();
157 View Code Duplication
            foreach ($curFragment->getPositionalPlaceholders() 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...
158
                $overallPlcHdr = new SqlPatternPlaceholder(
159
                    $sqlTorsoOffset + $plcHdr->getOffset(),
160
                    count($overallPosPlaceholders),
161
                    $plcHdr->getTypeName(),
162
                    $plcHdr->isTypeNameQuoted(),
163
                    $plcHdr->getSchemaName(),
164
                    $plcHdr->isSchemaNameQuoted()
165
                );
166
                $overallPosPlaceholders[] = $overallPlcHdr;
167
            }
168
            foreach ($curFragment->getNamedPlaceholderMap() as $name => $occurrences) {
169
                /** @var SqlPatternPlaceholder[] $occurrences */
170
                if (!isset($overallNamedPlaceholderMap[$name])) {
171
                    $overallNamedPlaceholderMap[$name] = [];
172
                }
173 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...
174
                    $overallPlcHdr = new SqlPatternPlaceholder(
175
                        $sqlTorsoOffset + $plcHdr->getOffset(),
176
                        $name,
177
                        $plcHdr->getTypeName(),
178
                        $plcHdr->isTypeNameQuoted(),
179
                        $plcHdr->getSchemaName(),
180
                        $plcHdr->isSchemaNameQuoted()
181
                    );
182
                    $overallNamedPlaceholderMap[$name][] = $overallPlcHdr;
183
                }
184
            }
185
186
            // values of parameters
187
            $plcHdrCnt = count($curFragment->getPositionalPlaceholders());
188
            $posParams = array_slice($fragmentsAndPositionalParams, $argsProcessed, $plcHdrCnt);
189
            if (count($posParams) == $plcHdrCnt) {
190
                $overallPosParams = array_merge($overallPosParams, $posParams);
191
            }
192
            else {
193
                $ord = StringUtils::englishOrd($curFragmentNum);
194
                throw new \InvalidArgumentException("Not enough positional parameters for the $ord fragment");
195
            }
196
197
            $curFragmentNum++;
198
            $argsProcessed += count($posParams);
199
200
            $curFragment =& $fragmentsAndPositionalParams[$argsProcessed];
201
            $argsProcessed++;
202
        } while (isset($curFragment));
203
204
        $overallPattern = new SqlPattern($overallSqlTorso, $overallPosPlaceholders, $overallNamedPlaceholderMap);
205
206
        $recipe = new static($overallPattern, $overallPosParams);
207
        $recipe->setParams($namedParamValues);
208
        return $recipe;
209
    }
210
211
    final private function __construct(SqlPattern $sqlPattern, array $positionalParameters)
212
    {
213
        $this->sqlPattern = $sqlPattern;
214
        $this->params = $positionalParameters;
215
        $this->unsatisfiedParams = array_fill_keys(array_keys($sqlPattern->getNamedPlaceholderMap()), true);
216
    }
217
218
    public function setParam($nameOrPosition, $value)
219
    {
220
        if (isset($this->unsatisfiedParams[$nameOrPosition])) {
221
            unset($this->unsatisfiedParams[$nameOrPosition]);
222
        }
223
        elseif (!array_key_exists($nameOrPosition, $this->params)) {
224
            throw new \InvalidArgumentException("The SQL pattern does not have parameter '$nameOrPosition'");
225
        }
226
227
        $this->params[$nameOrPosition] = $value;
228
        return $this;
229
    }
230
231
    public function setParams($paramMap)
232
    {
233
        foreach ($paramMap as $nameOrPosition => $value) {
234
            $this->setParam($nameOrPosition, $value);
235
        }
236
        return $this;
237
    }
238
239
    public function getSqlPattern() : SqlPattern
240
    {
241
        return $this->sqlPattern;
242
    }
243
244
    public function getParams() : array
245
    {
246
        return $this->params;
247
    }
248
249
    /**
250
     * @param ITypeDictionary $typeDictionary
251
     * @return string
252
     * @throws InvalidStateException when values for one or more named parameters has not been set
253
     * @throws UndefinedTypeException when some of the types used in the pattern are not defined
254
     */
255
    public function toSql(ITypeDictionary $typeDictionary) : string
256
    {
257
        if ($this->unsatisfiedParams) {
258
            $names = array_keys($this->unsatisfiedParams);
259
            if (count($names) == 1) {
260
                $msg = sprintf('Value for parameter "%s" has not been set.', $names[0]);
261
            }
262
            else {
263
                $msg = sprintf(
264
                    'Values for parameters %s and "%s" have not been set.',
265
                    array_map(function ($s) { return "\"$s\""; }, array_slice($names, 0, -1))
266
                );
267
            }
268
            throw new InvalidStateException($msg);
269
        }
270
271
        $gen = $this->sqlPattern->generateSql();
272
        while ($gen->valid()) {
273
            /** @var SqlPatternPlaceholder $placeholder */
274
            $placeholder = $gen->current();
275
            assert(
276
                array_key_exists($placeholder->getNameOrPosition(), $this->params),
277
                new NoDataException("Value for parameter {$placeholder->getNameOrPosition()} not set.")
278
            );
279
280
            $value = $this->params[$placeholder->getNameOrPosition()];
281
282
            if ($placeholder->getTypeName() !== null) {
283
                $typeName = $placeholder->getTypeName();
284
                if (!$placeholder->isTypeNameQuoted()) {
285
                    $typeName = mb_strtolower($typeName); // OPT: SqlPatternPlaceholder might also store the lower-case name, which might be cached
286
                }
287
                $schemaName = $placeholder->getSchemaName();
288
                if ($schemaName !== null) {
289
                    if (!$placeholder->isSchemaNameQuoted()) {
290
                        $schemaName = mb_strtolower($schemaName); // OPT: SqlPatternPlaceholder might also store the lower-case name, which might be cached
291
                    }
292
                }
293
                elseif ($placeholder->isTypeNameQuoted()) {
294
                    $schemaName = false;
295
                }
296
297
                $converter = $typeDictionary->requireTypeByName($typeName, $schemaName);
298
            }
299
            else {
300
                $converter = $typeDictionary->requireTypeByValue($value);
301
            }
302
            $serializedValue = $converter->serializeValue($value);
303
304
            $gen->send($serializedValue);
305
        }
306
307
        return $gen->getReturn();
308
    }
309
}
310