Completed
Push — master ( 5fdfe9...8a6726 )
by Ondřej
03:21
created

SqlRecipe   B

Complexity

Total Complexity 36

Size/Duplication

Total Lines 320
Duplicated Lines 6.88 %

Coupling/Cohesion

Components 1
Dependencies 9

Importance

Changes 0
Metric Value
wmc 36
lcom 1
cbo 9
dl 22
loc 320
rs 8.8
c 0
b 0
f 0

9 Methods

Rating   Name   Duplication   Size   Complexity  
A fromSql() 0 5 1
A fromPattern() 0 17 3
D fromFragments() 22 90 15
A __construct() 0 6 1
A setParam() 0 12 3
A setParams() 0 7 2
A getSqlPattern() 0 4 1
A getParams() 0 4 1
B toSql() 0 54 9

How to fix   Duplicated Code   

Duplicated Code

Duplicate code is one of the most pungent code smells. A rule that is often used is to re-structure code once it is duplicated in three or more places.

Common duplication problems, and corresponding solutions are:

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
/**
13
 * Recipe defined by an SQL query string or a parametrized SQL pattern.
14
 *
15
 * Note this class is not directly instantiable. Instead, use factory methods on subclasses.
16
 *
17
 * @see SqlPattern for thorough details on SQL patterns
18
 */
19
abstract class SqlRecipe
20
{
21
    /** @var SqlPattern */
22
    private $sqlPattern;
23
    /** @var array map: parameter name or position => supplied value */
24
    private $params;
25
    /** @var bool[] map: name of parameter which has not been set any value yet => <tt>true</tt> value */
26
    private $unsatisfiedParams;
27
28
29
    /**
30
     * Creates an SQL recipe from an SQL string.
31
     *
32
     * No parameter substitution is performed on the string - it is used as is.
33
     *
34
     * @param string $sql SQL string
35
     * @return static
36
     */
37
    public static function fromSql(string $sql) : self
38
    {
39
        $sqlPattern = new SqlPattern($sql, [], []);
40
        return new static($sqlPattern, []);
41
    }
42
43
    /**
44
     * Creates a new recipe from an SQL pattern.
45
     *
46
     * Values for all positional parameters required by the pattern must be given.
47
     *
48
     * Example:
49
     * <pre>
50
     * <?php
51
     * $recipe = new SqlRecipe(
52
     *   'SELECT *, %s:status FROM person WHERE role = %d AND email = %s',
53
     *   4, '[email protected]'
54
     * );
55
     * // results in "SELECT * FROM person WHERE role = 4 AND email = '[email protected]'"
56
     * </pre>
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\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 recipe 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 SqlRecipe::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 latter of which does not start with whitespace.
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
     * <pre>
101
     * <?php
102
     * $recipe = SqlRecipe::fromFragments(
103
     *   'SELECT * FROM person WHERE role = %d', 4, 'AND email = %s', '[email protected]'
104
     * );
105
     * // results in "SELECT * FROM person WHERE role = 4 AND email = '[email protected]'"
106
     * </pre>
107
     *
108
     * Performance considerations: parsing the SQL pattern, if given as a string, is done by the parser obtained by
109
     * {@link \Ivory\Ivory::getSqlPatternParser()}. Depending on Ivory configuration, the parser will cache the results
110
     * and reuse them for the same pattern next time.
111
     *
112
     * @internal Ivory design note: The single space added between each two fragments aspires to be more practical than
113
     * a mere concatenation, which would require the user to specify spaces where the next fragment immediately
114
     * continued with the query.
115
     *
116
     * @param string|SqlPattern $fragment
117
     * @param array ...$fragmentsAndPositionalParams
118
     *                                  further fragments (each of which is either a <tt>string</tt> or an
119
     *                                    {@link SqlPattern} object) and values of their parameters;
120
     *                                  the very last argument may be a map of values for named parameters to set
121
     *                                    immediately
122
     *
123
     * @return static
124
     * @throws \InvalidArgumentException when any fragment is not followed by the exact number of parameter values it
125
     *                                     requires
126
     */
127
    public static function fromFragments($fragment, ...$fragmentsAndPositionalParams) : self
128
    {
129
        $overallSqlTorso = '';
130
        $overallPosPlaceholders = [];
131
        $overallNamedPlaceholderMap = [];
132
        $overallPosParams = [];
133
134
        $namedParamValues = [];
135
136
        $curFragment = $fragment;
137
        $curFragmentNum = 1;
138
        $argsProcessed = 0;
139
        do {
140
            // process the fragment
141
            if (!$curFragment instanceof SqlPattern) {
142
                if (is_string($curFragment)) {
143
                    $parser = \Ivory\Ivory::getSqlPatternParser();
144
                    $curFragment = $parser->parse($curFragment);
145
                }
146
                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...
147
                    $argsProcessed > 0 && !array_key_exists($argsProcessed, $fragmentsAndPositionalParams))
148
                {
149
                    $namedParamValues = $curFragment;
150
                    break;
151
                }
152
                else {
153
                    $ord = StringUtils::englishOrd($curFragmentNum);
154
                    throw new \InvalidArgumentException("Invalid type of $ord fragment. Isn't it a misplaced parameter value?");
155
                }
156
            }
157
158
            // add to the overall pattern
159
            if ($argsProcessed > 0 && !preg_match('~^\s~', $curFragment->getSqlTorso())) {
160
                $overallSqlTorso .= ' ';
161
            }
162
            $sqlTorsoOffset = strlen($overallSqlTorso);
163
            $overallSqlTorso .= $curFragment->getSqlTorso();
164 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...
165
                $overallPlcHdr = new SqlPatternPlaceholder(
166
                    $sqlTorsoOffset + $plcHdr->getOffset(),
167
                    count($overallPosPlaceholders),
168
                    $plcHdr->getTypeName(),
169
                    $plcHdr->isTypeNameQuoted(),
170
                    $plcHdr->getSchemaName(),
171
                    $plcHdr->isSchemaNameQuoted()
172
                );
173
                $overallPosPlaceholders[] = $overallPlcHdr;
174
            }
175
            foreach ($curFragment->getNamedPlaceholderMap() as $name => $occurrences) {
176
                /** @var SqlPatternPlaceholder[] $occurrences */
177
                if (!isset($overallNamedPlaceholderMap[$name])) {
178
                    $overallNamedPlaceholderMap[$name] = [];
179
                }
180 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...
181
                    $overallPlcHdr = new SqlPatternPlaceholder(
182
                        $sqlTorsoOffset + $plcHdr->getOffset(),
183
                        $name,
184
                        $plcHdr->getTypeName(),
185
                        $plcHdr->isTypeNameQuoted(),
186
                        $plcHdr->getSchemaName(),
187
                        $plcHdr->isSchemaNameQuoted()
188
                    );
189
                    $overallNamedPlaceholderMap[$name][] = $overallPlcHdr;
190
                }
191
            }
192
193
            // values of parameters
194
            $plcHdrCnt = count($curFragment->getPositionalPlaceholders());
195
            $posParams = array_slice($fragmentsAndPositionalParams, $argsProcessed, $plcHdrCnt);
196
            if (count($posParams) == $plcHdrCnt) {
197
                $overallPosParams = array_merge($overallPosParams, $posParams);
198
            }
199
            else {
200
                $ord = StringUtils::englishOrd($curFragmentNum);
201
                throw new \InvalidArgumentException("Not enough positional parameters for the $ord fragment");
202
            }
203
204
            $curFragmentNum++;
205
            $argsProcessed += count($posParams);
206
207
            $curFragment =& $fragmentsAndPositionalParams[$argsProcessed];
208
            $argsProcessed++;
209
        } while (isset($curFragment));
210
211
        $overallPattern = new SqlPattern($overallSqlTorso, $overallPosPlaceholders, $overallNamedPlaceholderMap);
212
213
        $recipe = new static($overallPattern, $overallPosParams);
214
        $recipe->setParams($namedParamValues);
215
        return $recipe;
216
    }
217
218
    final private function __construct(SqlPattern $sqlPattern, array $positionalParameters)
219
    {
220
        $this->sqlPattern = $sqlPattern;
221
        $this->params = $positionalParameters;
222
        $this->unsatisfiedParams = array_fill_keys(array_keys($sqlPattern->getNamedPlaceholderMap()), true);
223
    }
224
225
    /**
226
     * Sets the value of a parameter in the SQL pattern.
227
     *
228
     * @param string|int $nameOrPosition name of the named parameter, or (zero-based) position of the positional
229
     *                                     parameter, respectively
230
     * @param mixed $value value of the parameter;
231
     *                     if the parameter is specified explicitly with its type, <tt>$value</tt> must correspond to
232
     *                       the type;
233
     *                     otherwise, the type of the parameter (and thus the conversion to be used) is inferred from
234
     *                       the type of <tt>$value</tt>
235
     * @return $this
236
     * @throws \InvalidArgumentException when the SQL pattern has no parameter of a given name or position
237
     */
238
    public function setParam($nameOrPosition, $value) : self
239
    {
240
        if (isset($this->unsatisfiedParams[$nameOrPosition])) {
241
            unset($this->unsatisfiedParams[$nameOrPosition]);
242
        }
243
        elseif (!array_key_exists($nameOrPosition, $this->params)) {
244
            throw new \InvalidArgumentException("The SQL pattern does not have parameter '$nameOrPosition'");
245
        }
246
247
        $this->params[$nameOrPosition] = $value;
248
        return $this;
249
    }
250
251
    /**
252
     * Sets values of several parameters in the SQL pattern.
253
     *
254
     * @param array|\Traversable $paramMap map: parameter name (or zero-based position) => parameter value
255
     * @return $this
256
     */
257
    public function setParams($paramMap) : self // PHP 7.1: declare $paramMap as iterable
1 ignored issue
show
Unused Code Comprehensibility introduced by
39% 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...
258
    {
259
        foreach ($paramMap as $nameOrPosition => $value) {
260
            $this->setParam($nameOrPosition, $value);
261
        }
262
        return $this;
263
    }
264
265
    public function getSqlPattern() : SqlPattern
266
    {
267
        return $this->sqlPattern;
268
    }
269
270
    /**
271
     * @return array map: parameter name or zero-based position => parameter value
272
     */
273
    public function getParams() : array
274
    {
275
        return $this->params;
276
    }
277
278
    /**
279
     * @param ITypeDictionary $typeDictionary
280
     * @return string
281
     * @throws InvalidStateException when values for one or more named parameters has not been set
282
     * @throws UndefinedTypeException when some of the types used in the pattern are not defined
283
     */
284
    public function toSql(ITypeDictionary $typeDictionary) : string
285
    {
286
        if ($this->unsatisfiedParams) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->unsatisfiedParams of type boolean[] 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...
287
            $names = array_keys($this->unsatisfiedParams);
288
            if (count($names) == 1) {
289
                $msg = sprintf('Value for parameter "%s" has not been set.', $names[0]);
290
            }
291
            else {
292
                $msg = sprintf(
293
                    'Values for parameters %s and "%s" have not been set.',
294
                    array_map(function ($s) { return "\"$s\""; }, array_slice($names, 0, -1))
295
                );
296
            }
297
            throw new InvalidStateException($msg);
298
        }
299
300
        $gen = $this->sqlPattern->generateSql();
301
        while ($gen->valid()) {
302
            /** @var SqlPatternPlaceholder $placeholder */
303
            $placeholder = $gen->current();
304
            assert(
305
                array_key_exists($placeholder->getNameOrPosition(), $this->params),
306
                new NoDataException("Value for parameter {$placeholder->getNameOrPosition()} not set.")
307
            );
308
309
            $value = $this->params[$placeholder->getNameOrPosition()];
310
311
            if ($placeholder->getTypeName() !== null) {
312
                $typeName = $placeholder->getTypeName();
313
                if (!$placeholder->isTypeNameQuoted()) {
314
                    $typeName = mb_strtolower($typeName); // OPT: SqlPatternPlaceholder might also store the lower-case name, which might be cached
315
                }
316
                $schemaName = $placeholder->getSchemaName();
317
                if ($schemaName !== null) {
318
                    if (!$placeholder->isSchemaNameQuoted()) {
319
                        $schemaName = mb_strtolower($schemaName); // OPT: SqlPatternPlaceholder might also store the lower-case name, which might be cached
320
                    }
321
                }
322
                elseif ($placeholder->isTypeNameQuoted()) {
323
                    $schemaName = false;
324
                }
325
326
                $converter = $typeDictionary->requireTypeByName($typeName, $schemaName);
327
            }
328
            else {
329
                $converter = $typeDictionary->requireTypeByValue($value);
330
            }
331
            $serializedValue = $converter->serializeValue($value);
332
333
            $gen->send($serializedValue);
334
        }
335
336
        return $gen->getReturn();
337
    }
338
}
339