Completed
Branch feature/pre-split (b5c37f)
by Anton
03:43
created

QueryCompiler::compileInsert()   B

Complexity

Conditions 3
Paths 3

Size

Total Lines 25
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Importance

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