Completed
Pull Request — master (#855)
by Gabriel
02:10
created

DoctrineExtension::composeMiniQuery()   A

Complexity

Conditions 5
Paths 10

Size

Total Lines 37

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 37
rs 9.0168
c 0
b 0
f 0
cc 5
nc 10
nop 3
1
<?php
2
3
namespace Doctrine\Bundle\DoctrineBundle\Twig;
4
5
use SqlFormatter;
6
use Symfony\Component\VarDumper\Cloner\Data;
7
use Twig_Extension;
8
use Twig_SimpleFilter;
9
10
/**
11
 * This class contains the needed functions in order to do the query highlighting
12
 */
13
class DoctrineExtension extends Twig_Extension
14
{
15
    /**
16
     * Number of maximum characters that one single line can hold in the interface
17
     *
18
     * @var int
19
     */
20
    private $maxCharWidth = 100;
21
22
    /**
23
     * Define our functions
24
     *
25
     * @return Twig_SimpleFilter[]
26
     */
27
    public function getFilters()
28
    {
29
        return [
30
            new Twig_SimpleFilter('doctrine_minify_query', [$this, 'minifyQuery'], ['deprecated' => true]),
31
            new Twig_SimpleFilter('doctrine_pretty_query', [$this, 'formatQuery'], ['is_safe' => ['html']]),
32
            new Twig_SimpleFilter('doctrine_replace_query_parameters', [$this, 'replaceQueryParameters']),
33
        ];
34
    }
35
36
    /**
37
     * Get the possible combinations of elements from the given array
38
     *
39
     * @param array $elements
40
     * @param int   $combinationsLevel
41
     *
42
     * @return array
43
     */
44
    private function getPossibleCombinations(array $elements, $combinationsLevel)
45
    {
46
        $baseCount = count($elements);
47
        $result    = [];
48
49
        if ($combinationsLevel === 1) {
50
            foreach ($elements as $element) {
51
                $result[] = [$element];
52
            }
53
54
            return $result;
55
        }
56
57
        $nextLevelElements = $this->getPossibleCombinations($elements, $combinationsLevel - 1);
58
59
        foreach ($nextLevelElements as $nextLevelElement) {
60
            $lastElement = $nextLevelElement[$combinationsLevel - 2];
61
            $found       = false;
62
63
            foreach ($elements as $key => $element) {
64
                if ($element === $lastElement) {
65
                    $found = true;
66
                    continue;
67
                }
68
69
                if ($found !== true || $key >= $baseCount) {
70
                    continue;
71
                }
72
73
                $tmp              = $nextLevelElement;
74
                $newCombination   = array_slice($tmp, 0);
75
                $newCombination[] = $element;
76
                $result[]         = array_slice($newCombination, 0);
77
            }
78
        }
79
80
        return $result;
81
    }
82
83
    /**
84
     * Shrink the values of parameters from a combination
85
     *
86
     * @param array $parameters
87
     * @param array $combination
88
     *
89
     * @return string
90
     */
91
    private function shrinkParameters(array $parameters, array $combination)
92
    {
93
        array_shift($parameters);
94
        $result = '';
95
96
        $maxLength  = $this->maxCharWidth;
97
        $maxLength -= count($parameters) * 5;
98
        $maxLength /= count($parameters);
99
100
        foreach ($parameters as $key => $value) {
101
            $isLarger = false;
102
103
            if (strlen($value) > $maxLength) {
104
                $value = wordwrap($value, $maxLength, "\n", true);
105
                $value = explode("\n", $value);
106
                $value = $value[0];
107
108
                $isLarger = true;
109
            }
110
            $value = self::escapeFunction($value);
111
112
            if (! is_numeric($value)) {
113
                $value = substr($value, 1, -1);
114
            }
115
116
            if ($isLarger) {
117
                $value .= ' [...]';
118
            }
119
120
            $result .= ' ' . $combination[$key] . ' ' . $value;
121
        }
122
123
        return trim($result);
124
    }
125
126
    /**
127
     * Attempt to compose the best scenario minified query so that a user could find it without expanding it
128
     *
129
     * @param string $query
130
     * @param array  $keywords
131
     * @param int    $required
132
     *
133
     * @return string
134
     */
135
    private function composeMiniQuery($query, array $keywords, $required)
136
    {
137
        // Extract the mandatory keywords and consider the rest as optional keywords
138
        $mandatoryKeywords = array_splice($keywords, 0, $required);
139
140
        $combinations      = [];
141
        $combinationsCount = count($keywords);
142
143
        // Compute all the possible combinations of keywords to match the query for
144
        while ($combinationsCount > 0) {
145
            $combinations = array_merge($combinations, $this->getPossibleCombinations($keywords, $combinationsCount));
146
            $combinationsCount--;
147
        }
148
149
        // Try and match the best case query pattern
150
        foreach ($combinations as $combination) {
151
            $combination = array_merge($mandatoryKeywords, $combination);
152
153
            $regexp = implode('(.*) ', $combination) . ' (.*)';
154
            $regexp = '/^' . $regexp . '/is';
155
156
            if (preg_match($regexp, $query, $matches)) {
157
                return $this->shrinkParameters($matches, $combination);
158
            }
159
        }
160
161
        // Try and match the simplest query form that contains only the mandatory keywords
162
        $regexp = implode(' (.*)', $mandatoryKeywords) . ' (.*)';
163
        $regexp = '/^' . $regexp . '/is';
164
165
        if (preg_match($regexp, $query, $matches)) {
166
            return $this->shrinkParameters($matches, $mandatoryKeywords);
167
        }
168
169
        // Fallback in case we didn't managed to find any good match (can we actually have that happen?!)
170
        return substr($query, 0, $this->maxCharWidth);
171
    }
172
173
    /**
174
     * Minify the query
175
     *
176
     * @param string $query
177
     *
178
     * @return string
179
     */
180
    public function minifyQuery($query)
181
    {
182
        $result   = '';
183
        $keywords = [];
184
        $required = 1;
185
186
        // Check if we can match the query against any of the major types
187
        switch (true) {
188 View Code Duplication
            case stripos($query, 'SELECT') !== false:
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...
189
                $keywords = ['SELECT', 'FROM', 'WHERE', 'HAVING', 'ORDER BY', 'LIMIT'];
190
                $required = 2;
191
                break;
192
193 View Code Duplication
            case stripos($query, 'DELETE') !== false:
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
                $keywords = ['DELETE', 'FROM', 'WHERE', 'ORDER BY', 'LIMIT'];
195
                $required = 2;
196
                break;
197
198 View Code Duplication
            case stripos($query, 'UPDATE') !== false:
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...
199
                $keywords = ['UPDATE', 'SET', 'WHERE', 'ORDER BY', 'LIMIT'];
200
                $required = 2;
201
                break;
202
203 View Code Duplication
            case stripos($query, 'INSERT') !== false:
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...
204
                $keywords = ['INSERT', 'INTO', 'VALUE', 'VALUES'];
205
                $required = 2;
206
                break;
207
208
            // If there's no match so far just truncate it to the maximum allowed by the interface
209
            default:
210
                $result = substr($query, 0, $this->maxCharWidth);
211
        }
212
213
        // If we had a match then we should minify it
214
        if ($result === '') {
215
            $result = $this->composeMiniQuery($query, $keywords, $required);
216
        }
217
218
        return $result;
219
    }
220
221
    /**
222
     * Escape parameters of a SQL query
223
     * DON'T USE THIS FUNCTION OUTSIDE ITS INTENDED SCOPE
224
     *
225
     * @internal
226
     *
227
     * @param mixed $parameter
228
     *
229
     * @return string
0 ignored issues
show
Documentation introduced by
Should the return type not be integer|double|string?

This check compares the return type specified in the @return annotation of a function or method doc comment with the types returned by the function and raises an issue if they mismatch.

Loading history...
230
     */
231
    public static function escapeFunction($parameter)
232
    {
233
        $result = $parameter;
234
235
        switch (true) {
236
            // Check if result is non-unicode string using PCRE_UTF8 modifier
237
            case is_string($result) && ! preg_match('//u', $result):
238
                $result = '0x' . strtoupper(bin2hex($result));
239
                break;
240
241
            case is_string($result):
242
                $result = "'" . addslashes($result) . "'";
243
                break;
244
245
            case is_array($result):
246
                foreach ($result as &$value) {
247
                    $value = static::escapeFunction($value);
248
                }
249
250
                $result = implode(', ', $result);
251
                break;
252
253
            case is_object($result):
254
                $result = addslashes((string) $result);
255
                break;
256
257
            case $result === null:
258
                $result = 'NULL';
259
                break;
260
261
            case is_bool($result):
262
                $result = $result ? '1' : '0';
263
                break;
264
        }
265
266
        return $result;
267
    }
268
269
    /**
270
     * Return a query with the parameters replaced
271
     *
272
     * @param string     $query
273
     * @param array|Data $parameters
274
     *
275
     * @return string
276
     */
277
    public function replaceQueryParameters($query, $parameters)
278
    {
279
        if ($parameters instanceof Data) {
280
            // VarDumper < 3.3 compatibility layer
281
            $parameters = method_exists($parameters, 'getValue') ? $parameters->getValue(true) : $parameters->getRawData();
0 ignored issues
show
Bug introduced by
The method getRawData() does not seem to exist on object<Symfony\Component\VarDumper\Cloner\Data>.

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
282
        }
283
284
        $i = 0;
285
286
        if (! array_key_exists(0, $parameters) && array_key_exists(1, $parameters)) {
287
            $i = 1;
288
        }
289
290
        return preg_replace_callback(
291
            '/\?|((?<!:):[a-z0-9_]+)/i',
292
            static function ($matches) use ($parameters, &$i) {
293
                $key = substr($matches[0], 1);
294
                if (! array_key_exists($i, $parameters) && ($key === false || ! array_key_exists($key, $parameters))) {
295
                    return $matches[0];
296
                }
297
298
                $value  = array_key_exists($i, $parameters) ? $parameters[$i] : $parameters[$key];
299
                $result = DoctrineExtension::escapeFunction($value);
300
                $i++;
301
302
                return $result;
303
            },
304
            $query
305
        );
306
    }
307
308
    /**
309
     * Formats and/or highlights the given SQL statement.
310
     *
311
     * @param  string $sql
312
     * @param  bool   $highlightOnly If true the query is not formatted, just highlighted
313
     *
314
     * @return string
315
     */
316
    public function formatQuery($sql, $highlightOnly = false)
317
    {
318
        SqlFormatter::$pre_attributes            = 'class="highlight highlight-sql"';
319
        SqlFormatter::$quote_attributes          = 'class="string"';
320
        SqlFormatter::$backtick_quote_attributes = 'class="string"';
321
        SqlFormatter::$reserved_attributes       = 'class="keyword"';
322
        SqlFormatter::$boundary_attributes       = 'class="symbol"';
323
        SqlFormatter::$number_attributes         = 'class="number"';
324
        SqlFormatter::$word_attributes           = 'class="word"';
325
        SqlFormatter::$error_attributes          = 'class="error"';
326
        SqlFormatter::$comment_attributes        = 'class="comment"';
327
        SqlFormatter::$variable_attributes       = 'class="variable"';
328
329
        if ($highlightOnly) {
330
            $html = SqlFormatter::highlight($sql);
331
            $html = preg_replace('/<pre class=".*">([^"]*+)<\/pre>/Us', '\1', $html);
332
        } else {
333
            $html = SqlFormatter::format($sql);
334
            $html = preg_replace('/<pre class="(.*)">([^"]*+)<\/pre>/Us', '<div class="\1"><pre>\2</pre></div>', $html);
335
        }
336
337
        return $html;
338
    }
339
340
    /**
341
     * Get the name of the extension
342
     *
343
     * @return string
344
     */
345
    public function getName()
346
    {
347
        return 'doctrine_extension';
348
    }
349
}
350