Completed
Branch develop (c2aa4c)
by Anton
05:17
created

QueryCompiler::quote()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 8
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 0 Features 1
Metric Value
c 2
b 0
f 1
dl 0
loc 8
rs 9.4286
cc 2
eloc 4
nc 2
nop 2
1
<?php
2
/**
3
 * Spiral Framework.
4
 *
5
 * @license   MIT
6
 * @author    Anton Titov (Wolfy-J)
7
 */
8
namespace Spiral\Database\Entities;
9
10
use Spiral\Core\Component;
11
use Spiral\Database\Exceptions\CompilerException;
12
use Spiral\Database\Injections\ExpressionInterface;
13
use Spiral\Database\Injections\FragmentInterface;
14
use Spiral\Database\Injections\ParameterInterface;
15
16
/**
17
 * Responsible for conversion of set of query parameters (where tokens, table names and etc) into
18
 * sql to be send into specific Driver.
19
 *
20
 * Source of Compiler must be optimized in nearest future.
21
 */
22
class QueryCompiler extends Component
23
{
24
    /**
25
     * Query types for parameter ordering.
26
     */
27
    const SELECT_QUERY = 'select';
28
    const UPDATE_QUERY = 'update';
29
    const DELETE_QUERY = 'delete';
30
    const INSERT_QUERY = 'insert';
31
32
    /**
33
     * Associated driver instance, may be required for some data assumptions.
34
     *
35
     * @var PDODriver
36
     */
37
    protected $driver = null;
38
39
    /**
40
     * Quotes names and expressions.
41
     *
42
     * @var Quoter
43
     */
44
    protected $quoter = null;
45
46
    /**
47
     * QueryCompiler constructor.
48
     *
49
     * @param PDODriver $driver
50
     * @param Quoter    $quoter
51
     */
52
    public function __construct(PDODriver $driver, Quoter $quoter)
53
    {
54
        $this->driver = $driver;
55
        $this->quoter = $quoter;
56
    }
57
58
    /**
59
     * Reset table aliases cache, required if same compiler used twice.
60
     *
61
     * @return $this
62
     */
63
    public function resetQuoter()
64
    {
65
        $this->quoter->reset();
66
67
        return $this;
68
    }
69
70
    /**
71
     * Query query identifier, if identified stated as table - table prefix must be added.
72
     *
73
     * @param string $identifier Identifier can include simple column operations and functions,
74
     *                           having "." in it will automatically force table prefix to first
75
     *                           value.
76
     * @param bool   $table      Set to true to let quote method know that identified is related
77
     *                           to table name.
78
     * @return mixed|string
79
     */
80
    public function quote($identifier, $table = false)
81
    {
82
        if ($identifier instanceof FragmentInterface) {
83
            return $this->prepareFragment($identifier);
84
        }
85
86
        return $this->quoter->quote($identifier, $table);
87
    }
88
89
    /**
90
     * Sort list of parameters in dbms query specific order, query type must be provided. This
91
     * method was used at times when delete and update queries supported joins, i might need to
92
     * drop it now.
93
     *
94
     * @param int   $queryType
95
     * @param array $whereParameters
96
     * @param array $onParameters
97
     * @param array $havingParameters
98
     * @param array $columnIdentifiers Column names (if any).
99
     * @return array
100
     */
101
    public function orderParameters(
102
        $queryType,
103
        array $whereParameters = [],
104
        array $onParameters = [],
105
        array $havingParameters = [],
106
        array $columnIdentifiers = []
107
    ) {
108
        return array_merge($columnIdentifiers, $onParameters, $whereParameters, $havingParameters);
109
    }
110
111
    /**
112
     * Create insert query using table names, columns and rowsets. Must support both - single and
113
     * batch inserts.
114
     *
115
     * @param string              $table
116
     * @param array               $columns
117
     * @param FragmentInterface[] $rowsets Every rowset has to be convertable into string. Raw data
118
     *                                     not allowed!
119
     * @return string
120
     * @throws CompilerException
121
     */
122
    public function compileInsert($table, array $columns, array $rowsets)
123
    {
124
        if (empty($columns)) {
125
            throw new CompilerException("Unable to build insert statement, columns must be set.");
126
        }
127
128
        if (empty($rowsets)) {
129
            throw new CompilerException(
130
                "Unable to build insert statement, at least one value set must be provided."
131
            );
132
        }
133
134
        //To add needed prefixes (if any)
135
        $table = $this->quote($table, true);
136
137
        //Compiling list of columns
138
        $columns = $this->prepareColumns($columns);
139
140
        //Simply joining every rowset
141
        $rowsets = join(",\n", $rowsets);
142
143
        return "INSERT INTO {$table} ({$columns})\nVALUES {$rowsets}";
144
    }
145
146
    /**
147
     * Create update statement.
148
     *
149
     * @param string $table
150
     * @param array  $updates
151
     * @param array  $whereTokens
152
     * @return string
153
     * @throws CompilerException
154
     */
155
    public function compileUpdate($table, array $updates, array $whereTokens = [])
156
    {
157
        $table = $this->quote($table, true);
158
159
        //Preparing update column statement
160
        $updates = $this->prepareUpdates($updates);
161
162
        //Where statement is optional for update queries
163
        $whereStatement = $this->optional("\nWHERE", $this->compileWhere($whereTokens));
164
165
        return rtrim("UPDATE {$table}\nSET {$updates} {$whereStatement}");
166
    }
167
168
    /**
169
     * Create delete statement.
170
     *
171
     * @param string $table
172
     * @param array  $whereTokens
173
     * @return string
174
     * @throws CompilerException
175
     */
176
    public function compileDelete($table, array $whereTokens = [])
177
    {
178
        $table = $this->quote($table, true);
179
180
        //Where statement is optional for delete query (which is weird)
181
        $whereStatement = $this->optional("\nWHERE", $this->compileWhere($whereTokens));
182
183
        return rtrim("DELETE FROM {$table} {$whereStatement}");
184
    }
185
186
    /**
187
     * Create select statement. Compiler must validly resolve table and column aliases used in
188
     * conditions and joins.
189
     *
190
     * @param array          $fromTables
191
     * @param boolean|string $distinct String only for PostgresSQL.
192
     * @param array          $columns
193
     * @param array          $joinTokens
194
     * @param array          $whereTokens
195
     * @param array          $havingTokens
196
     * @param array          $grouping
197
     * @param array          $ordering
198
     * @param int            $limit
199
     * @param int            $offset
200
     * @param array          $unionTokens
201
     * @return string
202
     * @throws CompilerException
203
     */
204
    public function compileSelect(
205
        array $fromTables,
206
        $distinct,
207
        array $columns,
208
        array $joinTokens = [],
209
        array $whereTokens = [],
210
        array $havingTokens = [],
211
        array $grouping = [],
212
        array $ordering = [],
213
        $limit = 0,
214
        $offset = 0,
215
        array $unionTokens = []
216
    ) {
217
        //This statement parts should be processed first to define set of table and column aliases
218
        $fromTables = $this->compileTables($fromTables);
219
220
        $joinsStatement = $this->optional(' ', $this->compileJoins($joinTokens), ' ');
221
222
        //Distinct flag (if any)
223
        $distinct = $this->optional(' ', $this->compileDistinct($distinct));
224
225
        //Columns are compiled after table names and joins to enshure aliases and prefixes
226
        $columns = $this->prepareColumns($columns);
227
228
        //A lot of constrain and other statements
229
        $whereStatement = $this->optional("\nWHERE", $this->compileWhere($whereTokens));
230
        $havingStatement = $this->optional("\nHAVING", $this->compileWhere($havingTokens));
231
        $groupingStatement = $this->optional("\nGROUP BY", $this->compileGrouping($grouping), ' ');
232
233
        //Union statement has new line at beginning of every union
234
        $unionsStatement = $this->optional("\n", $this->compileUnions($unionTokens));
235
        $orderingStatement = $this->optional("\nORDER BY ", $this->compileOrdering($ordering));
236
237
        $limingStatement = $this->optional("\n", $this->compileLimit($limit, $offset));
238
239
        //Initial statement have predictable order
240
        $statement = "SELECT{$distinct}\n{$columns}\nFROM {$fromTables}";
241
        $statement .= "{$joinsStatement}{$whereStatement}{$groupingStatement}{$havingStatement}";
242
        $statement .= "{$unionsStatement}{$orderingStatement}{$limingStatement}";
243
244
        return rtrim($statement);
245
    }
246
247
    /**
248
     * Quote and wrap column identifiers (used in insert statement compilation).
249
     *
250
     * @param array $columnIdentifiers
251
     * @param int   $maxLength Automatically wrap columns.
252
     * @return string
253
     */
254
    protected function prepareColumns(array $columnIdentifiers, $maxLength = 180)
255
    {
256
        //Let's quote every identifier
257
        $columnIdentifiers = array_map([$this, 'quote'], $columnIdentifiers);
258
259
        return wordwrap(join(', ', $columnIdentifiers), $maxLength);
260
    }
261
262
    /**
263
     * Prepare column values to be used in UPDATE statement.
264
     *
265
     * @param array $updates
266
     * @return array
267
     */
268
    protected function prepareUpdates(array $updates)
269
    {
270
        foreach ($updates as $column => &$value) {
271
            if ($value instanceof FragmentInterface) {
272
                $value = $this->prepareFragment($value);
273
            } else {
274
                //Simple value (such condition should never be met since every value has to be
275
                //wrapped using parameter interface)
276
                $value = '?';
277
            }
278
279
            $value = "{$this->quote($column)} = {$value}";
280
            unset($value);
281
        }
282
283
        return trim(join(",", $updates));
284
    }
285
286
    /**
287
     * Compile DISTINCT statement.
288
     *
289
     * @param mixed $distinct Not every DBMS support distinct expression, only Postgres does.
290
     * @return string
291
     */
292
    protected function compileDistinct($distinct)
293
    {
294
        if (empty($distinct)) {
295
            return '';
296
        }
297
298
        return "DISTINCT";
299
    }
300
301
    /**
302
     * Compile table names statement.
303
     *
304
     * @param array $tables
305
     * @return string
306
     */
307
    protected function compileTables(array $tables)
308
    {
309
        foreach ($tables as &$table) {
310
            $table = $this->quote($table, true);
311
            unset($table);
312
        }
313
314
        return join(', ', $tables);
315
    }
316
317
    /**
318
     * Compiler joins statement.
319
     *
320
     * @param array $joinTokens
321
     * @return string
322
     */
323
    protected function compileJoins(array $joinTokens)
324
    {
325
        $statement = '';
326
        foreach ($joinTokens as $table => $join) {
327
            $statement .= "\n" . $join['type'] . ' JOIN ' . $this->quote($table, true);
328
            $statement .= $this->optional("\n    ON", $this->compileWhere($join['on']));
329
        }
330
331
        return $statement;
332
    }
333
334
    /**
335
     * Compile union statement chunk. Keywords UNION and ALL will be included, this methods will
336
     * automatically move every union on new line.
337
     *
338
     * @param array $unionTokens
339
     * @return string
340
     */
341
    protected function compileUnions(array $unionTokens)
342
    {
343
        if (empty($unionTokens)) {
344
            return '';
345
        }
346
347
        $statement = '';
348
        foreach ($unionTokens as $union) {
349
            //First key is union type, second united query (no need to share compiler)
350
            $statement .= "\nUNION {$union[1]}\n({$union[0]})";
351
        }
352
353
        return ltrim($statement, "\n");
354
    }
355
356
    /**
357
     * Compile ORDER BY statement.
358
     *
359
     * @param array $ordering
360
     * @return string
361
     */
362
    protected function compileOrdering(array $ordering)
363
    {
364
        $result = [];
365
        foreach ($ordering as $order) {
366
            $result[] = $this->quote($order[0]) . ' ' . strtoupper($order[1]);
367
        }
368
369
        return join(', ', $result);
370
    }
371
372
    /**
373
     * Compiler GROUP BY statement.
374
     *
375
     * @param array $grouping
376
     * @return string
377
     */
378
    protected function compileGrouping(array $grouping)
379
    {
380
        $statement = '';
381
        foreach ($grouping as $identifier) {
382
            $statement .= $this->quote($identifier);
383
        }
384
385
        return $statement;
386
    }
387
388
    /**
389
     * Compile limit statement.
390
     *
391
     * @param int $limit
392
     * @param int $offset
393
     * @return string
394
     */
395
    protected function compileLimit($limit, $offset)
396
    {
397
        if (empty($limit) && empty($offset)) {
398
            return '';
399
        }
400
401
        $statement = '';
402
        if (!empty($limit)) {
403
            $statement = "LIMIT {$limit} ";
404
        }
405
406
        if (!empty($offset)) {
407
            $statement .= "OFFSET {$offset}";
408
        }
409
410
        return trim($statement);
411
    }
412
413
    /**
414
     * Compile where statement.
415
     *
416
     * @param array $tokens
417
     * @return string
418
     * @throws CompilerException
419
     */
420
    protected function compileWhere(array $tokens)
421
    {
422
        if (empty($tokens)) {
423
            return '';
424
        }
425
426
        $statement = '';
427
428
        $activeGroup = true;
429
        foreach ($tokens as $condition) {
430
            //OR/AND keyword
431
            $boolean = $condition[0];
432
433
            //See AbstractWhere
434
            $context = $condition[1];
435
436
            //First condition in group/query, no any AND, OR required
437
            if ($activeGroup) {
438
                //Kill AND, OR and etc.
1 ignored issue
show
Unused Code Comprehensibility introduced by
37% 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...
439
                $boolean = '';
440
441
                //Next conditions require AND or OR
1 ignored issue
show
Unused Code Comprehensibility introduced by
37% 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...
442
                $activeGroup = false;
443
            }
444
445
            /**
446
             * When context is string it usually represent control keyword/syntax such as opening
447
             * or closing braces.
448
             */
449
            if (is_string($context)) {
450
                if ($context == '(') {
451
                    //New where group.
452
                    $activeGroup = true;
453
                }
454
455
                $postfix = ' ';
456
                if ($context == '(') {
457
                    //We don't need space after opening brace
458
                    $postfix = '';
459
                }
460
461
                $statement .= ltrim("{$boolean} {$context}{$postfix}");
462
                continue;
463
            }
464
465
            if ($context instanceof FragmentInterface) {
466
                //Fragments has to be compiled separately
467
                $statement .= "{$boolean} {$this->prepareFragment($context)} ";
468
                continue;
469
            }
470
471
            if (!is_array($context)) {
472
                throw new CompilerException(
473
                    "Invalid where token, context expected to be an array."
474
                );
475
            }
476
477
            /**
478
             * This is "normal" where token which includes identifier, operator and value.
479
             */
480
            list($identifier, $operator, $value) = $context;
481
482
            //Identifier can be column name, expression or even query builder
483
            $identifier = $this->quote($identifier);
484
485
            //Value has to be prepared as well
486
            $placeholder = $this->prepareValue($value);
487
488
            if ($operator == 'BETWEEN' || $operator == 'NOT BETWEEN') {
489
                //Between statement has additional parameter
490
                $right = $this->prepareValue($context[3]);
491
492
                $statement .= "{$boolean} {$identifier} {$operator} {$placeholder} AND {$right} ";
493
                continue;
494
            }
495
496
            //Compiler can switch equal to IN if value points to array
497
            $operator = $this->prepareOperator($value, $operator);
498
499
            $statement .= "{$boolean} {$identifier} {$operator} {$placeholder} ";
500
        }
501
502
        if ($activeGroup) {
503
            throw new CompilerException("Unable to build where statement, unclosed where group.");
504
        }
505
506
        return trim($statement);
507
    }
508
509
    /**
510
     * Combine expression with prefix/postfix (usually SQL keyword) but only if expression is not
511
     * empty.
512
     *
513
     * @param string $prefix
514
     * @param string $expression
515
     * @param string $postfix
516
     * @return string
517
     */
518
    protected function optional($prefix, $expression, $postfix = '')
519
    {
520
        if (empty($expression)) {
521
            return '';
522
        }
523
524
        if ($prefix != "\n") {
525
            $prefix .= ' ';
526
        }
527
528
        return $prefix . $expression . $postfix;
529
    }
530
531
    /**
532
     * Resolve operator value based on value value. ;)
533
     *
534
     * @param mixed  $parameter
535
     * @param string $operator
536
     * @return string
537
     */
538
    protected function prepareOperator($parameter, $operator)
539
    {
540
        if (!$parameter instanceof ParameterInterface) {
541
            //Probably fragment
542
            return $operator;
543
        }
544
545
        if ($operator != '=' || is_scalar($parameter->getValue())) {
546
            //Doing nothing for non equal operators
547
            return $operator;
548
        }
549
550
        if (is_array($parameter->getValue())) {
551
            //Automatically switching between equal and IN
552
            return 'IN';
553
        }
554
555
        return $operator;
556
    }
557
558
    /**
559
     * Prepare value to be replaced into query (replace ?).
560
     *
561
     * @param string $value
562
     * @return string
563
     */
564
    protected function prepareValue($value)
565
    {
566
        if ($value instanceof FragmentInterface) {
567
            return $this->prepareFragment($value);
568
        }
569
570
        //Technically should never happen (but i prefer to keep this legacy code)
571
        return '?';
572
    }
573
574
    /**
575
     * Prepare where fragment to be injected into statement.
576
     *
577
     * @param FragmentInterface $context
578
     * @return string
579
     */
580
    protected function prepareFragment(FragmentInterface $context)
581
    {
582
        if ($context instanceof QueryBuilder) {
583
            //Nested queries has to be wrapped with braces
584
            return '(' . $context->sqlStatement($this) . ')';
585
        }
586
587
        if ($context instanceof ExpressionInterface) {
588
            //Fragments does not need braces around them
589
            return $context->sqlStatement($this);
590
        }
591
592
        return $context->sqlStatement();
593
    }
594
}