GitHub Access Token became invalid

It seems like the GitHub access token used for retrieving details about this repository from GitHub became invalid. This might prevent certain types of inspections from being run (in particular, everything related to pull requests).
Please ask an admin of your repository to re-new the access token on this website.
Passed
Pull Request — master (#2835)
by
unknown
06:01
created

DatabaseStatement   F

Complexity

Total Complexity 82

Size/Duplication

Total Lines 846
Duplicated Lines 0 %

Importance

Changes 5
Bugs 0 Features 0
Metric Value
c 5
b 0
f 0
dl 0
loc 846
rs 1.263
wmc 82

33 Methods

Rating   Name   Duplication   Size   Complexity  
A getDB() 0 3 1
A __destruct() 0 3 1
C generateOrderedSQLParts() 0 22 7
A results() 0 6 1
A tickString() 0 11 2
A getSeparatorForPartType() 0 6 1
B convertToParameterName() 0 29 4
A unsafe() 0 4 1
A getStatementStructure() 0 3 1
C asTickedString() 0 63 13
A generateSQL() 0 5 1
A getSQL() 0 3 1
A isSafe() 0 3 1
A finalize() 0 3 1
A isUsingPlaceholders() 0 3 1
A asProjectionList() 0 10 2
A containsSQLParts() 0 3 1
D splitFunctionArguments() 0 30 10
A unsafeAppendSQLPart() 0 12 2
B setValue() 0 17 5
A __construct() 0 8 2
A replaceTablePrefix() 0 10 2
A formatParameterName() 0 3 1
A usePlaceholders() 0 4 1
B appendValues() 0 17 5
A generateFormattedSQL() 0 12 2
A execute() 0 6 1
A asPlaceholdersList() 0 3 1
A asPlaceholderString() 0 8 3
A getSQLParts() 0 7 2
A getValues() 0 3 1
A asTickedList() 0 8 2
A validateFieldName() 0 8 2

How to fix   Complexity   

Complex Class

Complex classes like DatabaseStatement often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use DatabaseStatement, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
/**
4
 * @package toolkit
5
 */
6
7
/**
8
 * This class holds all the required data to enable dynamic SQL statement creation.
9
 * The way it currently works is by keeping an array for SQL parts. Each operation
10
 * must add their corresponding SQL part.
11
 *
12
 * When appending parts, developers should make sure that the SQL is safe.
13
 * The class also offers methods to sanitize and validate field values.
14
 *
15
 * Finally, the class can be inherited by specialized class for particular queries.
16
 *
17
 * @see DatabaseQuery
18
 * @see DatabaseCreate
19
 * @see DatabaseUpdate
20
 * @see DatabaseDelete
21
 * @see DatabaseShow
22
 * @see DatabaseCreate
23
 * @see DatabaseAlter
24
 * @see DatabaseDrop
25
 * @see DatabaseTruncate
26
 * @see DatabaseOptimize
27
 * @see DatabaseSet
28
 * @see DatabaseStatementException
29
 */
30
class DatabaseStatement
31
{
32
    /**
33
     * List of element delimiter
34
     * @var string
35
     */
36
    const LIST_DELIMITER = ', ';
37
38
    /**
39
     * The SQL values delimiter
40
     */
41
    const VALUES_DELIMITER = ',';
42
43
    /**
44
     * The SQL part delimiter
45
     * @var string
46
     */
47
    const STATEMENTS_DELIMITER = ' ';
48
49
    /**
50
     * The SQL part end of line
51
     */
52
    const FORMATTED_PART_EOL = "\n";
53
54
    /**
55
     * The SQL part tab character
56
     */
57
    const FORMATTED_PART_TAB = "\t";
58
59
    /**
60
     * The SQL part delimiter
61
     */
62
    const FORMATTED_PART_DELIMITER = self::FORMATTED_PART_EOL . self::FORMATTED_PART_TAB;
63
64
    /**
65
     * Regular Expression that matches SQL functions
66
     * @var string
67
     */
68
    const FCT_PATTERN = '/^([A-Za-z_]+)\((.*)\)$/';
69
70
    /**
71
     * The SQL functions arguments delimiter
72
     */
73
    const FCT_ARGS_DELIMITER = ',';
74
75
    /**
76
     * Regular Expression that matches SQL operators +, -, *, /
77
     * @var string
78
     */
79
    const OP_PATTERN = '/\s+([\-\+\*\/])\s+/';
80
81
    /**
82
     * Database object reference
83
     * @var Database
84
     */
85
    private $db;
86
87
    /**
88
     * SQL parts array
89
     * @var array
90
     */
91
    private $sql = [];
92
93
    /**
94
     * SQL values array
95
     * @see appendValues()
96
     * @var array
97
     */
98
    private $values = [];
99
100
    /**
101
     * SQL parameters cache
102
     * @see convertToParameterName()
103
     * @var array
104
     */
105
    private $parameters = [];
106
107
    /**
108
     * Placeholder flag: Developer should check if the statement supports name
109
     * parameters, which is on by default.
110
     * @var bool
111
     */
112
    private $usePlaceholders = false;
113
114
    /**
115
     * Safe flag: Allows old code to still inject illegal characters in their SQL.
116
     * @see Database::validateSQLQuery()
117
     * @var boolean
118
     */
119
    private $safe = true;
120
121
    /**
122
     * Creates a new DatabaseStatement object, linked to the $db parameter
123
     * and containing the optional $statement.
124
     *
125
     * @param Database $db
126
     *  The Database reference
127
     * @param string $statement
128
     *  An optional string of SQL that will be appended right from the start.
129
     *  Defaults to an empty string.
130
     */
131
    public function __construct(Database $db, $statement = '')
132
    {
133
        General::ensureType([
134
            'statement' => ['var' => $statement, 'type' => 'string'],
135
        ]);
136
        $this->db = $db;
137
        if (!empty($statement)) {
138
            $this->unsafeAppendSQLPart('statement', $statement);
139
        }
140
    }
141
142
    /**
143
     * Destroys all underlying resources
144
     */
145
    public function __destruct()
146
    {
147
        $this->db = null;
148
    }
149
150
    /**
151
     * Getter for the underlying database object.
152
     *
153
     * @return Database
154
     */
155
    final protected function getDB()
156
    {
157
        return $this->db;
158
    }
159
160
    /**
161
     * Getter for the underlying SQL parts array.
162
     *
163
     * @return array
164
     */
165
    final protected function getSQL()
166
    {
167
        return $this->sql;
168
    }
169
170
    /**
171
     * Returns all the parts for the specified type
172
     *
173
     * @param string $type
174
     *  The type value for the parts to retrieve
175
     * @return array
176
     */
177
    final public function getSQLParts($type)
178
    {
179
        return array_filter($this->getSQL(), function ($part) use ($type) {
180
            if (is_array($type)) {
0 ignored issues
show
introduced by
The condition is_array($type) is always false.
Loading history...
181
                return in_array(current(array_keys($part)), $type);
182
            }
183
            return current(array_keys($part)) === $type;
184
        });
185
    }
186
187
    /**
188
     * Returns true if the statement contains the specified part.
189
     *
190
     * @see getSQLParts()
191
     * @param string $type
192
     *  The type value for the parts to check for
193
     * @return bool
194
     */
195
    final public function containsSQLParts($type)
196
    {
197
        return !empty($this->getSQLParts($type));
198
    }
199
200
    /**
201
     * Returns the order in which parts needs to be generated.
202
     * Only those parts will be included when calling generateSQL().
203
     * When multiple parts can share the same order, use a sub-array.
204
     * Control characters can be used to merge parts together.
205
     * Those characters are:
206
     *  - `(` and `)` which wraps one or more parts in parenthesis
207
     *  - `,` which joins part with a comma if both the preceding and next parts are not empty
208
     *
209
     * @see getSQLParts()
210
     * @see generateSQL()
211
     * @return array
212
     */
213
    protected function getStatementStructure()
214
    {
215
        return ['statement'];
216
    }
217
218
    /**
219
     * Merges the ordered SQL parts array into a string, joined with the content of the
220
     * `STATEMENTS_DELIMITER` constant.
221
     * The order in which the part are merged are given by getStatementStructure().
222
     *
223
     * @see generateOrderedSQLParts()
224
     * @see getStatementStructure()
225
     * @return string
226
     *  The resulting SQL string
227
     */
228
    final public function generateSQL()
229
    {
230
        return implode(self::STATEMENTS_DELIMITER, array_map(function ($part) {
231
            return current($part);
232
        }, $this->generateOrderedSQLParts()));
0 ignored issues
show
Bug introduced by
$this->generateOrderedSQLParts() of type string is incompatible with the type array expected by parameter $arr1 of array_map(). ( Ignorable by Annotation )

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

232
        }, /** @scrutinizer ignore-type */ $this->generateOrderedSQLParts()));
Loading history...
233
    }
234
235
    /**
236
     * Merges the ordered SQL parts array into a string, joined with specific string in
237
     * order to create a formatted, human friendly representation of the resulting SQL.
238
     * The order in which the part are merged are given by getStatementStructure().
239
     * The string used for each SQL part is given by getSeparatorForPartType().
240
     *
241
     * @see FORMATTED_PART_DELIMITER
242
     * @see FORMATTED_PART_EOL
243
     * @see FORMATTED_PART_TAB
244
     * @see getSeparatorForPartType()
245
     * @see generateOrderedSQLParts()
246
     * @see getStatementStructure()
247
     * @return string
248
     *  The resulting formatted SQL string
249
     */
250
    final public function generateFormattedSQL()
251
    {
252
        $parts = $this->generateOrderedSQLParts();
253
        return array_reduce($parts, function ($memo, $part) {
0 ignored issues
show
Bug introduced by
$parts of type string is incompatible with the type array expected by parameter $input of array_reduce(). ( Ignorable by Annotation )

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

253
        return array_reduce(/** @scrutinizer ignore-type */ $parts, function ($memo, $part) {
Loading history...
254
            $type = current(array_keys($part));
255
            $value = current($part);
256
            $sep = $this->getSeparatorForPartType($type);
257
            if (!$memo) {
258
                return $value;
259
            }
260
            return "{$memo}{$sep}{$value}";
261
        }, null);
262
    }
263
264
    /**
265
     * Gets the proper separator string for the given $type SQL part type, when
266
     * generating a formatted SQL statement.
267
     * The default implementation simply returns value of the `STATEMENTS_DELIMITER` constant.
268
     *
269
     * @see generateFormattedSQL()
270
     * @param string $type
271
     *  The SQL part type.
272
     * @return string
273
     *  The string to use to separate the formatted SQL parts.
274
     */
275
    public function getSeparatorForPartType($type)
276
    {
277
        General::ensureType([
278
            'type' => ['var' => $type, 'type' => 'string'],
279
        ]);
280
        return self::STATEMENTS_DELIMITER;
281
    }
282
283
    /**
284
     * Creates the ordered SQL parts array.
285
     * The order in which the part sorted are given by getStatementStructure().
286
     *
287
     * @see getStatementStructure()
288
     * @return string
289
     *  The resulting SQL string
290
     */
291
    final public function generateOrderedSQLParts()
292
    {
293
        $allParts = $this->getStatementStructure();
294
        $orderedParts = [];
295
        foreach ($allParts as $ti => $type) {
296
            if (in_array($type, ['(', ')'])) {
297
                $orderedParts[] = [$type => $type];
298
                continue;
299
            } elseif ($type === self::VALUES_DELIMITER) {
300
                $before = $this->getSQLParts($allParts[$ti - 1]);
301
                $after = $this->getSQLParts($allParts[$ti + 1]);
302
                if (!empty($before) && !empty($after)) {
303
                    $orderedParts[] = [$type => $type];
304
                }
305
                continue;
306
            }
307
            $parts = $this->getSQLParts($type);
308
            foreach ($parts as $pt => $part) {
309
                $orderedParts[] = $part;
310
            }
311
        }
312
        return $orderedParts;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $orderedParts returns the type array<mixed,array>|array which is incompatible with the documented return type string.
Loading history...
313
    }
314
315
    /**
316
     * @internal
317
     * Appends part $part of type $type into the SQL parts array.
318
     * Type $type is just a tag value, used to classify parts.
319
     * This can allow things like filtering out some parts.
320
     *
321
     * Only allowed parts will be accepted. The only valid part by default is 'statement'.
322
     *
323
     * BEWARE: This method does not validate or sanitize anything, except the
324
     * type of both parameters, which must be string. This method should be
325
     * used as a last resort or with properly sanitized values.
326
     *
327
     * @see getStatementStructure()
328
     * @param string $type
329
     *  The type value for this part
330
     * @param string $part
331
     *  The actual SQL code part
332
     * @return DatabaseStatement
333
     *  The current instance
334
     * @throws DatabaseStatementException
335
     */
336
    final public function unsafeAppendSQLPart($type, $part)
337
    {
338
        General::ensureType([
339
            'type' => ['var' => $type, 'type' => 'string'],
340
            'part' => ['var' => $part, 'type' => 'string'],
341
        ]);
342
        if (!General::in_array_multi($type, $this->getStatementStructure(), true)) {
0 ignored issues
show
Unused Code introduced by
The call to General::in_array_multi() has too many arguments starting with true. ( Ignorable by Annotation )

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

342
        if (!General::/** @scrutinizer ignore-call */ in_array_multi($type, $this->getStatementStructure(), true)) {

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress. Please note the @ignore annotation hint above.

Loading history...
343
            $class = get_class($this);
344
            throw new DatabaseStatementException("SQL Part type `$type` is not valid for class `$class`");
345
        }
346
        $this->sql[] = [$type => $part];
347
        return $this;
348
    }
349
350
    /**
351
     * Getter for the array of SQL values sent with the statement
352
     * to the database server.
353
     *
354
     * @return array
355
     */
356
    final public function getValues()
357
    {
358
        return $this->values;
359
    }
360
361
    /**
362
     * Appends the specified $values to the SQL values array.
363
     * This is the proper way to send user input, as those values
364
     * are send along the SQL statement without any concatenation.
365
     * It is safer and faster.
366
     *
367
     * It supports keyed and numeric arrays.
368
     * When using a keyed arrays, keys should be used as SQL named parameters.
369
     * When using a numeric array, parameters should be place holders (?)
370
     *
371
     * @see usePlaceholders()
372
     * @see convertToParameterName()
373
     * @param array $values
374
     *  The values to send to the database
375
     * @return DatabaseStatement
376
     *  The current instance
377
     */
378
    final protected function appendValues(array $values)
379
    {
380
        if ($this->isUsingPlaceholders()) {
381
            $values = array_values($values);
382
        } else {
383
            foreach ($values as $key => $value) {
384
                if (is_string($key)) {
385
                    $safeKey = $this->convertToParameterName($key, $value);
386
                    if ($key !== $safeKey) {
387
                        unset($values[$key]);
388
                        $values[$safeKey] = $value;
389
                    }
390
                }
391
            }
392
        }
393
        $this->values = array_merge($this->values, $values);
394
        return $this;
395
    }
396
397
    /**
398
     * Statement parameter setter. This function bypasses the automatic parameter generation
399
     * to allow the developer to set values as if using PDO directly.
400
     * This is sometimes needed when dealing with complex custom queries.
401
     * You should rather consider to sub class the DatabaseStatement and use appendValues() instead.
402
     *
403
     * @param mixed $key
404
     *  The key of the value, either its index or name
405
     * @param mixed $value
406
     *  The actual user provided value
407
     * @return DatabaseStatement
408
     *  The current instance
409
     * @throws DatabaseStatementException
410
     *  If the key is not the proper type: numeric when using place holders, string if not.
411
     *  If the key is already set.
412
     */
413
    final public function setValue($key, $value)
414
    {
415
        if (General::intval($key) > 0) {
416
            $key = General::intval($key);
417
            if (!$this->isUsingPlaceholders()) {
418
                throw new DatabaseStatementException(
419
                    'Can not use numeric index when using named parameters'
420
                );
421
            }
422
        } elseif (!is_string($key)) {
423
            throw new DatabaseStatementException('Key for parameter must be a string');
424
        }
425
        if (isset($this->values[$key])) {
426
            throw new DatabaseStatementException("Value for parameter `$key` is already defined");
427
        }
428
        $this->values[$key] = $value;
429
        return $this;
430
    }
431
432
    /**
433
     * Enable the use of placeholders (?) in the query instead of named parameters.
434
     *
435
     * @return DatabaseStatement
436
     *  The current instance
437
     */
438
    final public function usePlaceholders()
439
    {
440
        $this->usePlaceholders = true;
441
        return $this;
442
    }
443
444
    /**
445
     * If the current statement uses placeholders (?).
446
     *
447
     * @return bool
448
     *  true is the statement uses placeholders
449
     */
450
    final public function isUsingPlaceholders()
451
    {
452
        return $this->usePlaceholders;
453
    }
454
455
    /**
456
     * Marks the statement as not safe.
457
     * This disables strict validation
458
     *
459
     * @return DatabaseStatement
460
     *  The current instance
461
     */
462
    final public function unsafe()
463
    {
464
        $this->safe = false;
465
        return $this;
466
    }
467
468
    /**
469
     * If the current statement is deem safe.
470
     * Safe statements are validated more strictly
471
     *
472
     * @return bool
473
     *  true is the statement uses placeholders
474
     */
475
    final public function isSafe()
476
    {
477
        return $this->safe;
478
    }
479
480
    /**
481
     * @internal This method is not meant to be called directly. Use execute().
482
     * Appends any remaining part of the statement.
483
     * Called just before validation and the actual sending of the statement to
484
     * the SQL server.
485
     *
486
     * @see execute()
487
     * @return DatabaseStatement
488
     *  The current instance
489
     */
490
    public function finalize()
491
    {
492
        return $this;
493
    }
494
495
    /**
496
     * Send the query and all associated values to the server for execution.
497
     * Calls finalize before sending creating and sending the query to the server.
498
     *
499
     * @see Database::execute()
500
     * @return DatabaseStatementResult
501
     * @throws DatabaseException
502
     */
503
    final public function execute()
504
    {
505
        return $this
506
            ->finalize()
507
            ->getDB()
508
            ->execute($this);
509
    }
510
511
    /**
512
     * Factory function that creates a new DatabaseStatementResult based upon the $result
513
     * and $stm parameters.
514
     * Child classes can overwrite this method to return a specialized version of the
515
     * DatabaseStatementResult class.
516
     *
517
     * @param bool $success
518
     *  If the DatabaseStatement creating this instance succeeded or not.
519
     * @param PDOStatement $stm
520
     *  The PDOStatement created by the execution of the DatabaseStatement.
521
     * @return DatabaseStatementResult
522
     */
523
    public function results($success, PDOStatement $stm)
524
    {
525
        General::ensureType([
526
            'success' => ['var' => $success, 'type' => 'bool'],
527
        ]);
528
        return new DatabaseStatementResult($success, $stm);
529
    }
530
531
    /**
532
     * @internal
533
     * Given a string, replace the default table prefixes with the
534
     * table prefix for this database instance.
535
     *
536
     * @param string $query
537
     * @return string
538
     */
539
    final public function replaceTablePrefix($table)
540
    {
541
        General::ensureType([
542
            'table' => ['var' => $table, 'type' => 'string'],
543
        ]);
544
        if ($this->getDB()->getPrefix() != 'tbl_') {
545
            $table = preg_replace('/tbl_(\S+?)([\s\.,]|$)/', $this->getDB()->getPrefix() .'\\1\\2', trim($table));
546
        }
547
548
        return $table;
549
    }
550
551
    /**
552
     * @internal
553
     * Given a valid field name, returns its variant as a SQL parameter.
554
     * If the $key string is numeric, it will default to placeholders.
555
     * If enabled, it will use named parameters.
556
     *
557
     * @see validateFieldName()
558
     * @see isUsingPlaceholders()
559
     * @see usePlaceholders()
560
     * @see convertToParameterName()
561
     * @param string $key
562
     *  The key from which to derive the parameter name from
563
     * @param mixed $value
564
     *  The associated value for this key
565
     * @return string
566
     *  The parameter expression
567
     */
568
    final public function asPlaceholderString($key, $value)
569
    {
570
        if (!$this->isUsingPlaceholders() && General::intval($key) === -1) {
571
            $this->validateFieldName($key);
572
            $key = $this->convertToParameterName($key, $value);
573
            return ":$key";
574
        }
575
        return '?';
576
    }
577
578
    /**
579
     * Given an array of valid field names, maps `asPlaceholderString` on each
580
     * keys and then implodes the resulting array using LIST_DELIMITER
581
     *
582
     * @see asPlaceholderString()
583
     * @see LIST_DELIMITER
584
     * @param array $values
585
     * @return void
586
     */
587
    final public function asPlaceholdersList(array $values)
588
    {
589
        return implode(self::LIST_DELIMITER, General::array_map([$this, 'asPlaceholderString'], $values));
0 ignored issues
show
Bug Best Practice introduced by
The expression return implode(self::LIS...lderString'), $values)) returns the type string which is incompatible with the documented return type void.
Loading history...
Bug introduced by
array($this, 'asPlaceholderString') of type array<integer,string|DatabaseStatement> is incompatible with the type string expected by parameter $function of General::array_map(). ( Ignorable by Annotation )

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

589
        return implode(self::LIST_DELIMITER, General::array_map(/** @scrutinizer ignore-type */ [$this, 'asPlaceholderString'], $values));
Loading history...
590
    }
591
592
    /**
593
     * @internal Actually does the tick formatting on the $value string.
594
     * It makes sure all ticks are removed before validating the value.
595
     * If the string contains a dot, it will explode it before adding the ticks.
596
     *
597
     * @uses validateFieldName()
598
     * @param string $value
599
     *  The value to surrounded with ticks
600
     * @return string
601
     *  The value surrounded by ticks
602
     */
603
    final public function tickString($value)
604
    {
605
        General::ensureType([
606
            'value' => ['var' => $value, 'type' => 'string'],
607
        ]);
608
        $value = str_replace('`', '', $value);
609
        $this->validateFieldName($value);
610
        if (strpos($value, '.') !== false) {
611
            return implode('.', array_map([$this, 'asTickedString'], explode('.', $value)));
612
        }
613
        return "`$value`";
614
    }
615
616
    /**
617
     * @internal Splits the arguments of function calls.
618
     * Arguments are only separated: no formatting is made.
619
     * Each value should to pass to asTickedString() before being used in SQL queries.
620
     *
621
     * @param string $arguments
622
     *  The argument string to parse
623
     * @return array
624
     *  The arguments array
625
     */
626
    final public function splitFunctionArguments($arguments)
627
    {
628
        $arguments = str_split($arguments);
629
        $current = [];
630
        $args = [];
631
        $openParenthesisCount = 0;
632
        foreach ($arguments as $char) {
633
            if (!trim($char)) {
634
                continue;
635
            } elseif ($openParenthesisCount === 0 && $char === self::FCT_ARGS_DELIMITER) {
636
                if (!empty($current)) {
637
                    $args[] = implode('', $current);
638
                }
639
                $current = [];
640
                continue;
641
            }
642
            $current[] = $char;
643
            if ($char === '(') {
644
                $openParenthesisCount++;
645
            } elseif ($char === ')') {
646
                $openParenthesisCount--;
647
            }
648
        }
649
        if ($openParenthesisCount !== 0) {
650
            throw new DatabaseStatementException('Imbalanced number of parenthesis in function arguments');
651
        }
652
        if (!empty($current)) {
653
            $args[] = implode('', $current);
654
        }
655
        return $args;
656
    }
657
658
    /**
659
     * @internal
660
     * Given some value, it will create a ticked string, i.e. "`string`".
661
     * If the $value parameter is:
662
     *  1. an array, it will call asPlaceholdersList();
663
     *  2. the string '*', it will keep it as is;
664
     *  3. a string matching a function call, it will parse it;
665
     *  4. a string with a mathematical operator (+, -, *, /), it will parse it;
666
     *  5. a string with comma, it will explode that string and call
667
     *     asTickedList() with the resulting array;
668
     *  6. a string starting with a colon, it will be used as named parameter;
669
     *  7. a plain string, it will surround all words with ticks.
670
     *
671
     * For other type of value, it will throw an Exception.
672
     *
673
     * @see asTickedList()
674
     * @uses tickString()
675
     * @uses splitFunctionArguments()
676
     * @param string|array $value
677
     *  The value or list of values to surround with ticks.
678
     * @param string $alias
679
     *  Used as an alias, create `x` AS `y` expressions.
680
     * @return string
681
     *  The resulting ticked string or list
682
     * @throws Exception
683
     */
684
    final public function asTickedString($value, $alias = null)
685
    {
686
        if (!$value) {
687
            return '';
688
        }
689
        // 1. deal with array
690
        if (is_array($value)) {
691
            return $this->asTickedList($value);
692
        }
693
        General::ensureType([
694
            'value' => ['var' => $value, 'type' => 'string'],
695
        ]);
696
697
        $fctMatches = [];
698
        $opMatches = [];
699
        $value = trim($value);
700
701
        // 2. '*'
702
        if ($value === '*') {
703
            return $value;
704
        // 3. function
705
        } elseif (preg_match(self::FCT_PATTERN, $value, $fctMatches) === 1) {
706
            $args = $this->splitFunctionArguments($fctMatches[2]);
707
            $fxCall = $fctMatches[1] . '(' . $this->asTickedList($args) . ')';
708
            if ($alias) {
709
                $alias = $this->tickString($alias);
710
                return "$fxCall AS $alias";
711
            }
712
            return $fxCall;
713
        // 4. op
714
        } elseif (preg_match(self::OP_PATTERN, $value, $opMatches) === 1) {
715
            $op = $opMatches[1];
716
            if (!$op) {
717
                throw new DatabaseStatementException("Failed to parse operator in `$value`");
718
            }
719
            $parts = array_map('trim', explode($op, $value, 2));
720
            $parts = array_map(function ($p) {
721
                // TODO: add support for params
722
                $ip = General::intval($p);
723
                return $ip === -1 ? $this->asTickedString($p) : "$ip";
724
            }, $parts);
725
            $value = implode(" $op ", $parts);
726
            if ($alias) {
727
                $alias = $this->tickString($alias);
728
                return "($value) AS $alias";
729
            }
730
            return $value;
731
        // 5. comma
732
        } elseif (strpos($value, self::VALUES_DELIMITER) !== false) {
733
            return $this->asTickedList(explode(self::VALUES_DELIMITER, $value));
734
        // 6. colon
735
        } elseif (strpos($value, ':') === 0) {
736
            $this->validateFieldName(substr($value, 1));
737
            return $value;
738
        }
739
740
        // 7. plain string
741
        $value = $this->tickString($value);
742
        if ($alias) {
743
            $alias = $this->tickString($alias);
744
            return "$value AS $alias";
745
        }
746
        return $value;
747
    }
748
749
    /**
750
     * @internal
751
     * Given an array, this method will call asTickedString() on each values and
752
     * then implode the results with LIST_DELIMITER.
753
     * If the array contains named keys, they become the value and the value in the array
754
     * is used as an alias, create `x` AS `y` expressions.
755
     *
756
     * @see asTickedString()
757
     * @param array $values
758
     * @return string
759
     *  The resulting list of ticked strings
760
     */
761
    final public function asTickedList(array $values)
762
    {
763
        return implode(self::LIST_DELIMITER, General::array_map(function ($key, $value) {
0 ignored issues
show
Bug introduced by
function(...) { /* ... */ } of type callable is incompatible with the type string expected by parameter $function of General::array_map(). ( Ignorable by Annotation )

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

763
        return implode(self::LIST_DELIMITER, General::array_map(/** @scrutinizer ignore-type */ function ($key, $value) {
Loading history...
764
            if (General::intval($key) === -1) {
765
                return $this->asTickedString($key, $value);
766
            }
767
            return $this->asTickedString($value);
768
        }, $values));
769
    }
770
771
    /**
772
     * @internal
773
     * Given an array, this method will call asTickedList() on each values and
774
     * then implode the results with LIST_DELIMITER.
775
     * If the value is a DatabaseQuery object, the key is used as the alias.
776
     *
777
     * @see asTickedList()
778
     * @param array $values
779
     * @return string
780
     *  The resulting list of ticked strings
781
     */
782
    final public function asProjectionList(array $values)
783
    {
784
        return implode(self::LIST_DELIMITER, General::array_map(function ($key, $value) {
0 ignored issues
show
Bug introduced by
function(...) { /* ... */ } of type callable is incompatible with the type string expected by parameter $function of General::array_map(). ( Ignorable by Annotation )

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

784
        return implode(self::LIST_DELIMITER, General::array_map(/** @scrutinizer ignore-type */ function ($key, $value) {
Loading history...
785
            if ($value instanceof DatabaseSubQuery) {
786
                $sql = $value->generateSQL();
787
                $key = $this->asTickedString($key);
788
                return "($sql) AS $key";
789
            }
790
            return $this->asTickedList([$key => $value]);
791
        }, $values));
792
    }
793
794
    /**
795
     * @internal
796
     * This method validates that the string $field is a valid field name
797
     * in SQL. If it is not, it throws DatabaseStatementException
798
     *
799
     * @param string $field
800
     * @return void
801
     * @throws DatabaseStatementException
802
     * @throws Exception
803
     */
804
    final protected function validateFieldName($field)
805
    {
806
        General::ensureType([
807
            'field' => ['var' => $field, 'type' => 'string'],
808
        ]);
809
        if (preg_match('/^[0-9a-zA-Z_]+$/', $field) === false) {
810
            throw new DatabaseStatementException(
811
                "Field name '$field' is not valid since it contains illegal characters"
812
            );
813
        }
814
    }
815
816
    /**
817
     * @internal
818
     * This function converts a valid field name into a suitable value
819
     * to use as a SQL parameter name.
820
     * It also makes sure that the returned parameter name is not currently used
821
     * for the specified $field, $value pair.
822
     *
823
     * @see formatParameterName()
824
     * @see validateFieldName()
825
     * @see appendValues()
826
     * @param string $field
827
     *  The field name, as passed in the public API of the statement
828
     * @param mixed $value
829
     *  The associated value for this field
830
     * @return string
831
     *  The sanitized parameter name
832
     */
833
    final public function convertToParameterName($field, $value)
834
    {
835
        General::ensureType([
836
            'value' => ['var' => $field, 'type' => 'string'],
837
        ]);
838
        $field = str_replace(['-', '.'], '_', $field);
839
        $field = preg_replace('/[^0-9a-zA-Z_]+/', '', $field);
840
        $field = $this->formatParameterName($field);
841
842
        $uniqueParameterKey = sha1(serialize($field) . serialize($value));
843
        // Have we seen this (field, value) pair ?
844
        if (isset($this->parameters[$uniqueParameterKey])) {
845
            return $this->parameters[$uniqueParameterKey];
846
        }
847
        // Have we seen this field ?
848
        $fieldLookup = $field;
849
        $fieldCount = 1;
850
        while (isset($this->parameters[$fieldLookup])) {
851
            $fieldCount++;
852
            $fieldLookup = "$field$fieldCount";
853
        }
854
        // Special case for null
855
        if ($value === null) {
856
            $fieldLookup = "_null_";
857
        }
858
        // Saved both for later
859
        $this->parameters[$uniqueParameterKey] = $this->parameters[$fieldLookup] = $fieldLookup;
860
861
        return $fieldLookup;
862
    }
863
864
    /**
865
     * @internal
866
     * Formats the given $parameter name to be used as SQL parameter.
867
     *
868
     * @param string $parameter
869
     *  The parameter name
870
     * @return string
871
     *  The formatted parameter name
872
     */
873
    protected function formatParameterName($parameter)
874
    {
875
        return $parameter;
876
    }
877
}
878