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
Push — master ( c341ca...96b029 )
by Brendan
12:53 queued 05:42
created

CreateTable::diffForeigns()   B

Complexity

Conditions 7
Paths 9

Size

Total Lines 21
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 11
CRAP Score 7

Importance

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