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.
Completed
Pull Request — master (#60)
by Burhan
03:25
created

CreateTable::parseIndex()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 5
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

Changes 0
Metric Value
cc 1
eloc 3
nc 1
nop 3
dl 0
loc 5
ccs 0
cts 4
cp 0
crap 2
rs 10
c 0
b 0
f 0
1
<?php
2
namespace Graze\Morphism\Parse;
3
4
use RuntimeException;
5
6
/**
7
 * Represents a table definition.
8
 */
9
class CreateTable
10
{
11
    /** @var string */
12
    private $name = '';
13
14
    /** @var ColumnDefinition[] */
15
    public $columns = [];
16
17
    /** @var IndexDefinition[] definitions of non-foreign keys */
18
    public $indexes = [];
19
20
    /** @var IndexDefinition[] definitions of foreign keys */
21
    public $foreigns = [];
22
23
    /** @var TableOptions */
24
    public $options = null;
25
26
    /** @var array */
27
    private $covers = [];
28
29
    /**
30
     * Constructor.
31
     *
32
     * @param CollationInfo $databaseCollation
33
     */
34 1
    public function __construct(CollationInfo $databaseCollation)
35
    {
36 1
        $this->options = new TableOptions($databaseCollation);
37 1
    }
38
39
    /**
40
     * @return string
41
     */
42 1
    public function getName()
43
    {
44 1
        return $this->name;
45
    }
46
47
    /**
48
     * Sets the storage engine the table is assumed to use, unless
49
     * explicitly overridden via an ENGINE= clause at the end of
50
     * the table definition.
51
     *
52
     * @param string $engine
53
     * @return void
54
     */
55
    public function setDefaultEngine($engine)
56
    {
57
        $this->options->setDefaultEngine($engine);
58
    }
59
60
    /**
61
     * Parses a table definition from $stream.
62
     *
63
     * The DDL may be of the form 'CREATE TABLE ...' or 'CREATE TABLE IF NOT EXISTS ...'.
64
     *
65
     * An exception will be thrown if a valid CREATE TABLE statement cannot be recognised.
66
     *
67
     * @param TokenStream $stream
68
     */
69
    public function parse(TokenStream $stream)
70
    {
71
        if ($stream->consume('CREATE TABLE')) {
72
            $stream->consume('IF NOT EXISTS');
73
        } else {
74
            throw new RuntimeException("Expected CREATE TABLE");
75
        }
76
77
        $this->name = $stream->expectName();
78
        $stream->expectOpenParen();
79
80
        while (true) {
81
            $hasConstraintKeyword = $stream->consume('CONSTRAINT');
82
            if ($stream->consume('PRIMARY KEY')) {
83
                $this->parseIndex($stream, 'PRIMARY KEY');
84
            } elseif ($stream->consume('KEY') ||
85
                $stream->consume('INDEX')
86
            ) {
87
                if ($hasConstraintKeyword) {
88
                    throw new RuntimeException("Bad CONSTRAINT");
89
                }
90
                $this->parseIndex($stream, 'KEY');
91
            } elseif ($stream->consume('FULLTEXT')) {
92
                if ($hasConstraintKeyword) {
93
                    throw new RuntimeException("Bad CONSTRAINT");
94
                }
95
                $stream->consume('KEY') || $stream->consume('INDEX');
96
                $this->parseIndex($stream, 'FULLTEXT KEY');
97
            } elseif ($stream->consume('UNIQUE')) {
98
                $stream->consume('KEY') || $stream->consume('INDEX');
99
                $this->parseIndex($stream, 'UNIQUE KEY');
100
            } elseif ($stream->consume('FOREIGN KEY')) {
101
                $this->parseIndex($stream, 'FOREIGN KEY');
102
            } elseif ($hasConstraintKeyword) {
103
                $constraint = $stream->expectName();
104
                if ($stream->consume('PRIMARY KEY')) {
105
                    $this->parseIndex($stream, 'PRIMARY KEY', $constraint);
106
                } elseif ($stream->consume('UNIQUE')) {
107
                    $stream->consume('KEY') || $stream->consume('INDEX');
108
                    $this->parseIndex($stream, 'UNIQUE KEY', $constraint);
109
                } elseif ($stream->consume('FOREIGN KEY')) {
110
                    $this->parseIndex($stream, 'FOREIGN KEY', $constraint);
111
                } else {
112
                    throw new RuntimeException("Bad CONSTRAINT");
113
                }
114
            } else {
115
                $this->parseColumn($stream);
116
            }
117
            $token = $stream->nextToken();
118
            if ($token->eq(Token::SYMBOL, ',')) {
119
                continue;
120
            } elseif ($token->eq(Token::SYMBOL, ')')) {
121
                break;
122
            } else {
123
                throw new RuntimeException("Expected ',' or ')'");
124
            }
125
        }
126
127
        $this->processTimestamps();
128
        $this->processIndexes();
129
        $this->processAutoIncrement();
130
        $this->parseTableOptions($stream);
131
        $this->processColumnCollations();
132
    }
133
134
    /**
135
     * Returns the table's collation.
136
     *
137
     * @return CollationInfo
138
     */
139
    public function getCollation()
140
    {
141
        return $this->options->collation;
142
    }
143
144
    /**
145
     * Returns an array of SQL DDL statements to create the table.
146
     *
147
     * @return array
148
     */
149
    public function getDDL()
150
    {
151
        $lines = [];
152
        foreach ($this->columns as $column) {
153
            $lines[] = "  " . $column->toString($this->getCollation());
154
        }
155
        foreach ($this->indexes as $index) {
156
            $lines[] = "  " . $index->toString();
157
        }
158
        foreach ($this->foreigns as $foreign) {
159
            $lines[] = "  " . $foreign->toString();
160
        }
161
162
        $text = "CREATE TABLE " . Token::escapeIdentifier($this->name) . " (\n" .
163
            implode(",\n", $lines) .
164
            "\n" .
165
            ")";
166
167
        $options = $this->options->toString();
168
        if ($options !== '') {
169
            $text .= " " . $this->options->toString();
170
        }
171
172
        return [$text];
173
    }
174
175
    /**
176
     * @param TokenStream $stream
177
     */
178
    private function parseColumn(TokenStream $stream)
179
    {
180
        $column = new ColumnDefinition();
181
        $column->parse($stream);
182
        if (array_key_exists(strtolower($column->name), $this->columns)) {
183
            throw new RuntimeException("Duplicate column name '" . $column->name . "'");
184
        }
185
        $this->columns[strtolower($column->name)] = $column;
186
        $this->indexes = array_merge(
187
            $this->indexes,
188
            $column->indexes
189
        );
190
    }
191
192
    /**
193
     * @param TokenStream $stream
194
     * @param string $type
195
     * @param string|null $constraint
196
     */
197
    private function parseIndex(TokenStream $stream, $type, $constraint = null)
198
    {
199
        $index = new IndexDefinition();
200
        $index->parse($stream, $type, $constraint);
201
        $this->indexes[] = $index;
202
    }
203
204
    /**
205
     * @param TokenStream $stream
206
     */
207
    private function parseTableOptions(TokenStream $stream)
208
    {
209
        $this->options->parse($stream);
210
    }
211
212
    private function processTimestamps()
213
    {
214
        // To specify automatic properties, use the DEFAULT CURRENT_TIMESTAMP
215
        // and ON UPDATE CURRENT_TIMESTAMP clauses. The order of the clauses
216
        // does not matter. If both are present in a column definition, either
217
        // can occur first.
218
219
        // collect all timestamps
220
        $ts = [];
221
        foreach ($this->columns as $column) {
222
            if ($column->type === 'timestamp') {
223
                $ts[] = $column;
224
            }
225
        }
226
        if (count($ts) === 0) {
227
            return;
228
        }
229
230
        // none of NULL, DEFAULT or ON UPDATE CURRENT_TIMESTAMP have been specified
231
        if (!$ts[0]->nullable && is_null($ts[0]->default) && !$ts[0]->onUpdateCurrentTimestamp) {
232
            $ts[0]->nullable = false;
233
            $ts[0]->default = 'CURRENT_TIMESTAMP';
234
            $ts[0]->onUpdateCurrentTimestamp = true;
235
        }
236
237
        // [[ this restriction no longer exists as of MySQL 5.6.5 and MariaDB 10.0.1 ]]
238
239
        // One TIMESTAMP column in a table can have the current timestamp as
240
        // the default value for initializing the column, as the auto-update
241
        // value, or both. It is not possible to have the current timestamp
242
        // be the default value for one column and the auto-update value for
243
        // another column.
244
245
        // $specials = 0;
0 ignored issues
show
Unused Code Comprehensibility introduced by
43% 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...
246
        // foreach($ts as $column) {
0 ignored issues
show
Unused Code Comprehensibility introduced by
64% 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...
247
        //     if ($column->default === 'CURRENT_TIMESTAMP' ||
0 ignored issues
show
Unused Code Comprehensibility introduced by
47% 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...
248
        //         $column->onUpdateCurrentTimestamp
249
        //     ) {
250
        //         if (++$specials > 1) {
0 ignored issues
show
Unused Code Comprehensibility introduced by
54% 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...
251
        //             throw new RuntimeException("There can be only one TIMESTAMP column with CURRENT_TIMESTAMP in DEFAULT or ON UPDATE clause");
0 ignored issues
show
Unused Code Comprehensibility introduced by
60% 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...
252
        //         }
253
        //     }
254
        // }
255
256
        foreach ($ts as $column) {
257
            if (!$column->nullable && is_null($column->default)) {
258
                $column->default = '0000-00-00 00:00:00';
259
            }
260
        }
261
    }
262
263
    private function processIndexes()
264
    {
265
        // check indexes are sane wrt available columns
266
        foreach ($this->indexes as $index) {
267
            foreach ($index->columns as $indexColumn) {
268
                $indexColumnName = $indexColumn['name'];
269
                if (!array_key_exists(strtolower($indexColumnName), $this->columns)) {
270
                    throw new RuntimeException("Key column '$indexColumnName' doesn't exist in table");
271
                }
272
            }
273
        }
274
275
        // figure out all sequences of columns covered by non-FK indexes
276
        foreach ($this->indexes as $index) {
277
            if ($index->type !== 'FOREIGN KEY') {
278
                foreach ($index->getCovers() as $cover) {
279
                    $lookup = implode('\0', $cover);
280
                    $this->covers[$lookup] = true;
281
                }
282
            }
283
        }
284
285
        $indexes = [];
286
        $foreigns = [];
287
        $ibfkCounter = 0;
288
289
        foreach ($this->indexes as $index) {
290
            if ($index->type === 'FOREIGN KEY') {
291
                // TODO - doesn't correctly deal with indexes like foo(10)
0 ignored issues
show
Coding Style Best Practice introduced by
Comments for TODO tasks are often forgotten in the code; it might be better to use a dedicated issue tracker.
Loading history...
292
                $lookup = implode('\0', $index->getColumns());
293
                if (!array_key_exists($lookup, $this->covers)) {
294
                    $newIndex = new IndexDefinition();
295
                    $newIndex->type = 'KEY';
296
                    $newIndex->columns = $index->columns;
297
                    if (!is_null($index->constraint)) {
298
                        $newIndex->name = $index->constraint;
299
                    } elseif (!is_null($index->name)) {
300
                        $newIndex->name = $index->name;
301
                    }
302
                    $indexes[] = $newIndex;
303
                }
304
                $foreign = new IndexDefinition();
305
                if (is_null($index->constraint)) {
306
                    $foreign->constraint = $this->name . '_ibfk_' . ++$ibfkCounter;
0 ignored issues
show
Coding Style introduced by
Increment and decrement operators must be bracketed when used in string concatenation
Loading history...
307
                } else {
308
                    $foreign->constraint = $index->constraint;
309
                }
310
                $foreign->type = 'FOREIGN KEY';
311
                $foreign->columns = $index->columns;
312
                $foreign->reference = $index->reference;
313
                $foreigns[] = $foreign;
314
            } else {
315
                $indexes[] = $index;
316
            }
317
        }
318
319
        // now synthesise names for any unnamed indexes,
320
        // and collect indexes by type
321
        $usedName = [];
322
        $keyTypes = [
323
            'PRIMARY KEY',
324
            'UNIQUE KEY',
325
            'KEY',
326
            'FULLTEXT KEY',
327
            'FOREIGN KEY',
328
        ];
329
        $indexesByType = array_fill_keys($keyTypes, []);
330
        foreach ($indexes as $index) {
331
            $name = $index->name;
332
            if ($index->type === 'PRIMARY KEY') {
333
                $name = 'PRIMARY';
334
            } elseif (is_null($name)) {
335
                $base = $index->columns[0]['name'];
336
                $name = $base;
337
                $i = 1;
338
                while (isset($usedName[$name])) {
339
                    $name = $base . '_' . ++$i;
0 ignored issues
show
Coding Style introduced by
Increment and decrement operators must be bracketed when used in string concatenation
Loading history...
340
                }
341
                $index->name = $name;
342
            } elseif (array_key_exists(strtolower($name), $usedName)) {
343
                throw new RuntimeException("Duplicate key name '$name'");
344
            }
345
            $index->name = $name;
346
            $usedName[strtolower($name)] = true;
347
348
            $indexesByType[$index->type][] = $index;
349
        }
350
351
        if (count($indexesByType['PRIMARY KEY']) > 1) {
352
            throw new RuntimeException("Multiple PRIMARY KEYs defined");
353
        }
354
355
        foreach ($indexesByType['PRIMARY KEY'] as $pk) {
356
            foreach ($pk->columns as $indexColumn) {
357
                $column = $this->columns[strtolower($indexColumn['name'])];
358
                if ($column->nullable) {
359
                    $column->nullable = false;
360
                    if (is_null($column->default)) {
361
                        $column->default = $column->getUninitialisedValue();
362
                    }
363
                }
364
            }
365
        }
366
367
        $this->indexes = [];
368
        foreach (array_reduce($indexesByType, 'array_merge', []) as $index) {
369
            $this->indexes[$index->name] = $index;
370
        }
371
        foreach ($foreigns as $foreign) {
372
            $this->foreigns[] = $foreign;
373
        }
374
    }
375
376
    private function processAutoIncrement()
377
    {
378
        $count = 0;
379
        foreach ($this->columns as $column) {
380
            if ($column->autoIncrement) {
381
                if (++$count > 1) {
382
                    throw new RuntimeException("There can be only one AUTO_INCREMENT column");
383
                }
384
                if (! array_key_exists($column->name, $this->covers)) {
385
                    throw new RuntimeException("AUTO_INCREMENT column must be defined as a key");
386
                }
387
            }
388
        }
389
    }
390
391
    private function processColumnCollations()
392
    {
393
        foreach ($this->columns as $column) {
394
            $column->applyTableCollation($this->getCollation());
395
        }
396
    }
397
398
    /**
399
     * Returns ALTER TABLE statement to transform this table into the one
400
     * represented by $that. If the tables are already equivalent, just
401
     * returns the empty string.
402
     *
403
     * $flags        |
404
     * :-------------|----
405
     * 'alterEngine' | (bool) include ALTER TABLE ... ENGINE= [default: true]
406
     *
407
     * @param CreateTable $that
408
     * @param array $flags
409
     * @return string[]
410
     */
411
    public function diff(CreateTable $that, array $flags = [])
412
    {
413
        $flags += [
414
            'alterEngine' => true
415
        ];
416
417
        $alters = array_merge(
418
            $this->diffColumns($that),
419
            $this->diffIndexes($that),
420
            $this->diffOptions($that, [
421
                'alterEngine' => $flags['alterEngine']
422
            ])
423
        );
424
425
        if (count($alters) === 0) {
426
            return [];
427
        }
428
429
        return ["ALTER TABLE " . Token::escapeIdentifier($this->name) . "\n" . implode(",\n", $alters)];
430
    }
431
432
    /**
433
     * @param CreateTable $that
434
     * @return array
435
     */
436
    private function diffColumns(CreateTable $that)
437
    {
438
        $alters = [];
439
        $permutation = [];
440
        foreach (array_keys($this->columns) as $columnName) {
441
            if (array_key_exists($columnName, $that->columns)) {
442
                $permutation[] = $columnName;
443
            } else {
444
                $alters[] = "DROP COLUMN " . Token::escapeIdentifier($columnName);
445
            }
446
        }
447
448
        $prevColumn = null;
449
        $thatPosition = " FIRST";
450
        $j = 0;
451
        foreach ($that->columns as $columnName => $column) {
452
            if (array_key_exists($columnName, $this->columns)) {
453
                // An existing column is being changed
454
                $thisDefinition = $this->columns[$columnName]->toString($this->getCollation());
455
                $thatDefinition = $that->columns[$columnName]->toString($that->getCollation());
456
457
                // about to 'add' $columnName - get its location in the currently
458
                // permuted state of the tabledef
459
                $i = array_search($columnName, $permutation);
460
461
                // figure out the column it currently sits after, in case we
462
                // need to change it
463
                $thisPosition = ($i === 0) ? " FIRST" : " AFTER " . Token::escapeIdentifier($permutation[$i - 1]);
464
465
                if ($thisDefinition !== $thatDefinition ||
466
                    $thisPosition   !== $thatPosition
467
                ) {
468
                    $alter = "MODIFY COLUMN " . $thatDefinition;
469
470
                    // position has changed
471
                    if ($thisPosition !== $thatPosition) {
472
                        $alter .= $thatPosition;
473
474
                        // We need to update our permutation to reflect the new position.
475
                        // Column is being inserted at position $j, and is currently residing at $i.
476
477
                        // remove from current location
478
                        array_splice($permutation, /** @scrutinizer ignore-type */ $i, 1, []);
479
480
                        // insert at new location
481
                        array_splice($permutation, $j, 0, $columnName);
482
                    }
483
484
                    $alters[] = $alter;
485
                }
486
            } else {
487
                // A new column is being added
488
                $alter = "ADD COLUMN " . $column->toString($this->getCollation());
489
                if ($j < count($permutation)) {
490
                    $alter .= $thatPosition;
491
                }
492
                $alters[] = $alter;
493
494
                $i = is_null($prevColumn) ? 0 : 1 + array_search($prevColumn, $permutation);
495
                array_splice($permutation, $i, 0, [$columnName]);
496
            }
497
498
            $prevColumn = $columnName;
499
            $thatPosition = " AFTER " . Token::escapeIdentifier($prevColumn);
500
            $j++;
501
        }
502
503
        return $alters;
504
    }
505
506
    /**
507
     * @param CreateTable $that
508
     * @return array
509
     */
510
    private function diffIndexes(CreateTable $that)
511
    {
512
        $alters = [];
513
514
        foreach ($this->indexes as $indexName => $index) {
515
            if (!array_key_exists($indexName, $that->indexes) ||
516
                $index->toString() !== $that->indexes[$indexName]->toString()
517
            ) {
518
                switch ($index->type) {
519
                    case 'PRIMARY KEY':
520
                        $alter = "DROP PRIMARY KEY";
521
                        break;
522
523
                // TODO - foreign keys???
0 ignored issues
show
Coding Style Best Practice introduced by
Comments for TODO tasks are often forgotten in the code; it might be better to use a dedicated issue tracker.
Loading history...
524
525
                    default:
526
                        $alter = "DROP KEY " . Token::escapeIdentifier($indexName);
527
                        break;
528
                }
529
                $alters[] = $alter;
530
            }
531
        }
532
533
        foreach ($that->indexes as $indexName => $index) {
534
            if (!array_key_exists($indexName, $this->indexes) ||
535
                $index->toString() !== $this->indexes[$indexName]->toString()
536
            ) {
537
                $alters[] = "ADD " . $index->toString();
538
            }
539
        }
540
541
        return $alters;
542
    }
543
544
    /**
545
     * @param CreateTable $that
546
     * @param array $flags
547
     * @return array
548
     */
549
    private function diffOptions(CreateTable $that, array $flags = [])
550
    {
551
        $flags += [
552
            'alterEngine' => true
553
        ];
554
        $diff = $this->options->diff($that->options, [
555
            'alterEngine' => $flags['alterEngine']
556
        ]);
557
        return ($diff == '') ? [] : [$diff];
558
    }
559
}
560