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 (#61)
by Brendan
02:21
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 1
Bugs 0 Features 0
Metric Value
cc 7
eloc 10
nc 9
nop 1
dl 0
loc 21
ccs 11
cts 11
cp 1
crap 7
rs 8.8333
c 1
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
    /** @var bool */
30
    private $addIndexForForeignKey = true;
31
32
    /**
33
     * Constructor.
34
     *
35
     * @param CollationInfo $databaseCollation
36
     */
37 86
    public function __construct(CollationInfo $databaseCollation)
38
    {
39 86
        $this->options = new TableOptions($databaseCollation);
40 86
    }
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 84
    public function setDefaultEngine($engine)
59
    {
60 84
        $this->options->setDefaultEngine($engine);
61 84
    }
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
    public function setAddIndexForForeignKey($addIndexForForeignKey)
69
    {
70
        $this->addIndexForForeignKey = $addIndexForForeignKey;
71
    }
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 83
    public function parse(TokenStream $stream)
83
    {
84 83
        if ($stream->consume('CREATE TABLE')) {
85 82
            $stream->consume('IF NOT EXISTS');
86
        } else {
87 1
            throw new RuntimeException("Expected CREATE TABLE");
88
        }
89
90 82
        $this->name = $stream->expectName();
91 82
        $stream->expectOpenParen();
92
93 82
        while (true) {
94 82
            $hasConstraintKeyword = $stream->consume('CONSTRAINT');
95 82
            if ($stream->consume('PRIMARY KEY')) {
96 10
                $this->parseIndex($stream, 'PRIMARY KEY');
97 82
            } elseif ($stream->consume('KEY') ||
98 82
                $stream->consume('INDEX')
99
            ) {
100 14
                if ($hasConstraintKeyword) {
101 2
                    throw new RuntimeException("Bad CONSTRAINT");
102
                }
103 12
                $this->parseIndex($stream, 'KEY');
104 82
            } 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 82
            } elseif ($stream->consume('UNIQUE')) {
111 5
                $stream->consume('KEY') || $stream->consume('INDEX');
112 5
                $this->parseIndex($stream, 'UNIQUE KEY');
113 82
            } elseif ($stream->consume('FOREIGN KEY')) {
114 9
                $this->parseIndex($stream, 'FOREIGN KEY');
115 82
            } elseif ($hasConstraintKeyword) {
116 6
                $constraint = $stream->expectName();
117 6
                if ($stream->consume('PRIMARY KEY')) {
118 1
                    $this->parseIndex($stream, 'PRIMARY KEY', $constraint);
119 5
                } elseif ($stream->consume('UNIQUE')) {
120 1
                    $stream->consume('KEY') || $stream->consume('INDEX');
121 1
                    $this->parseIndex($stream, 'UNIQUE KEY', $constraint);
122 4
                } elseif ($stream->consume('FOREIGN KEY')) {
123 3
                    $this->parseIndex($stream, 'FOREIGN KEY', $constraint);
124
                } else {
125 6
                    throw new RuntimeException("Bad CONSTRAINT");
126
                }
127
            } else {
128 82
                $this->parseColumn($stream);
129
            }
130 79
            $token = $stream->nextToken();
131 79
            if ($token->eq(Token::SYMBOL, ',')) {
132 60
                continue;
133 73
            } elseif ($token->eq(Token::SYMBOL, ')')) {
134 72
                break;
135
            } else {
136 1
                throw new RuntimeException("Expected ',' or ')'");
137
            }
138
        }
139
140 72
        $this->processTimestamps();
141 72
        $this->processIndexes();
142 68
        $this->processAutoIncrement();
143 66
        $this->parseTableOptions($stream);
144 66
        $this->processColumnCollations();
145 66
    }
146
147
    /**
148
     * Returns the table's collation.
149
     *
150
     * @return CollationInfo
151
     */
152 66
    public function getCollation()
153
    {
154 66
        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 82
    private function parseColumn(TokenStream $stream)
192
    {
193 82
        $column = new ColumnDefinition();
194 82
        $column->parse($stream);
195 79
        if (array_key_exists(strtolower($column->name), $this->columns)) {
196 2
            throw new RuntimeException("Duplicate column name '" . $column->name . "'");
197
        }
198 79
        $this->columns[strtolower($column->name)] = $column;
199 79
        $this->indexes = array_merge(
200 79
            $this->indexes,
201 79
            $column->indexes
202
        );
203 79
    }
204
205
    /**
206
     * @param TokenStream $stream
207
     * @param string $type
208
     * @param string|null $constraint
209
     */
210 37
    private function parseIndex(TokenStream $stream, $type, $constraint = null)
211
    {
212 37
        $index = new IndexDefinition();
213 37
        $index->parse($stream, $type, $constraint);
214 37
        $this->indexes[] = $index;
215 37
    }
216
217
    /**
218
     * @param TokenStream $stream
219
     */
220 66
    private function parseTableOptions(TokenStream $stream)
221
    {
222 66
        $this->options->parse($stream);
223 66
    }
224
225 72
    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 72
        $ts = [];
234 72
        foreach ($this->columns as $column) {
235 72
            if ($column->type === 'timestamp') {
236 72
                $ts[] = $column;
237
            }
238
        }
239 72
        if (count($ts) === 0) {
240 60
            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 72
    private function processIndexes()
277
    {
278
        // check indexes are sane wrt available columns
279 72
        foreach ($this->indexes as $index) {
280 43
            foreach ($index->columns as $indexColumn) {
281 43
                $indexColumnName = $indexColumn['name'];
282 43
                if (!array_key_exists(strtolower($indexColumnName), $this->columns)) {
283 43
                    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 71
        foreach ($this->indexes as $index) {
290 42
            if ($index->type !== 'FOREIGN KEY') {
291 35
                foreach ($index->getCovers() as $cover) {
292 35
                    $lookup = implode('\0', $cover);
293 42
                    $this->covers[$lookup] = true;
294
                }
295
            }
296
        }
297
298 71
        $indexes = [];
299 71
        $foreigns = [];
300 71
        $ibfkCounter = 0;
301
302 71
        foreach ($this->indexes as $index) {
303 42
            if ($index->type === 'FOREIGN KEY') {
304 12
                if ($this->addIndexForForeignKey) {
305
                    // TODO - doesn't correctly deal with indexes like foo(10)
306 12
                    $lookup = implode('\0', $index->getColumns());
307 12
                    if (!array_key_exists($lookup, $this->covers)) {
308 10
                        $newIndex = new IndexDefinition();
309 10
                        $newIndex->type = 'KEY';
310 10
                        $newIndex->columns = $index->columns;
311 10
                        if (!is_null($index->constraint)) {
312 3
                            $newIndex->name = $index->constraint;
313 7
                        } elseif (!is_null($index->name)) {
314 1
                            $newIndex->name = $index->name;
315
                        }
316 10
                        $indexes[] = $newIndex;
317
                    }
318
                }
319
320 12
                $foreign = new IndexDefinition();
321 12
                if (is_null($index->constraint)) {
322 9
                    $foreign->constraint = $this->name . '_ibfk_' . ++$ibfkCounter;
323
                } else {
324 3
                    $foreign->constraint = $index->constraint;
325
                }
326 12
                $foreign->type = 'FOREIGN KEY';
327 12
                $foreign->columns = $index->columns;
328 12
                $foreign->reference = $index->reference;
329 12
                $foreigns[] = $foreign;
330
            } else {
331 42
                $indexes[] = $index;
332
            }
333
        }
334
335
        // now synthesise names for any unnamed indexes,
336
        // and collect indexes by type
337 71
        $usedName = [];
338
        $keyTypes = [
339 71
            'PRIMARY KEY',
340
            'UNIQUE KEY',
341
            'KEY',
342
            'FULLTEXT KEY',
343
            'FOREIGN KEY',
344
        ];
345 71
        $indexesByType = array_fill_keys($keyTypes, []);
346 71
        foreach ($indexes as $index) {
347 42
            $name = $index->name;
348 42
            if ($index->type === 'PRIMARY KEY') {
349 17
                $name = 'PRIMARY';
350 26
            } 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 12
            } elseif (array_key_exists(strtolower($name), $usedName)) {
359 2
                throw new RuntimeException("Duplicate key name '$name'");
360
            }
361 42
            $index->name = $name;
362 42
            $usedName[strtolower($name)] = true;
363
364 42
            $indexesByType[$index->type][] = $index;
365
        }
366
367 69
        if (count($indexesByType['PRIMARY KEY']) > 1) {
368 1
            throw new RuntimeException("Multiple PRIMARY KEYs defined");
369
        }
370
371 68
        foreach ($indexesByType['PRIMARY KEY'] as $pk) {
372 16
            foreach ($pk->columns as $indexColumn) {
373 16
                $column = $this->columns[strtolower($indexColumn['name'])];
374 16
                if ($column->nullable) {
375 10
                    $column->nullable = false;
376 10
                    if (is_null($column->default)) {
377 16
                        $column->default = $column->getUninitialisedValue();
378
                    }
379
                }
380
            }
381
        }
382
383 68
        $this->indexes = [];
384 68
        foreach (array_reduce($indexesByType, 'array_merge', []) as $index) {
385 39
            $this->indexes[$index->name] = $index;
386
        }
387 68
        foreach ($foreigns as $foreign) {
388 12
            $this->foreigns[$foreign->constraint] = $foreign;
389
        }
390 68
    }
391
392 68
    private function processAutoIncrement()
393
    {
394 68
        $count = 0;
395 68
        foreach ($this->columns as $column) {
396 68
            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 68
                    throw new RuntimeException("AUTO_INCREMENT column must be defined as a key");
402
                }
403
            }
404
        }
405 66
    }
406
407 66
    private function processColumnCollations()
408
    {
409 66
        foreach ($this->columns as $column) {
410 66
            $column->applyTableCollation($this->getCollation());
411
        }
412 66
    }
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 13
    public function diff(CreateTable $that, array $flags = [])
428
    {
429
        $flags += [
430 13
            'alterEngine' => true
431
        ];
432
433 13
        $alters = array_merge(
434 13
            $this->diffColumns($that),
435 13
            $this->diffIndexes($that),
436 13
            $this->diffForeigns($that),
437 13
            $this->diffOptions($that, [
438 13
                'alterEngine' => $flags['alterEngine']
439
            ])
440
        );
441
442 13
        if (count($alters) === 0) {
443 1
            return [];
444
        }
445
446 12
        return ["ALTER TABLE " . Token::escapeIdentifier($this->name) . "\n" . implode(",\n", $alters)];
447
    }
448
449
    /**
450
     * @param CreateTable $that
451
     * @return array
452
     */
453 13
    private function diffColumns(CreateTable $that)
454
    {
455 13
        $alters = [];
456 13
        $permutation = [];
457 13
        foreach (array_keys($this->columns) as $columnName) {
458 13
            if (array_key_exists($columnName, $that->columns)) {
459 13
                $permutation[] = $columnName;
460
            } else {
461 13
                $alters[] = "DROP COLUMN " . Token::escapeIdentifier($columnName);
462
            }
463
        }
464
465 13
        $prevColumn = null;
466 13
        $thatPosition = " FIRST";
467 13
        $j = 0;
468 13
        foreach ($that->columns as $columnName => $column) {
469 13
            if (array_key_exists($columnName, $this->columns)) {
470
                // An existing column is being changed
471 13
                $thisDefinition = $this->columns[$columnName]->toString($this->getCollation());
472 13
                $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 13
                $i = array_search($columnName, $permutation);
477
478
                // figure out the column it currently sits after, in case we
479
                // need to change it
480 13
                $thisPosition = ($i === 0) ? " FIRST" : " AFTER " . Token::escapeIdentifier($permutation[$i - 1]);
481
482 13
                if ($thisDefinition !== $thatDefinition ||
483 13
                    $thisPosition   !== $thatPosition
484
                ) {
485 6
                    $alter = "MODIFY COLUMN " . $thatDefinition;
486
487
                    // position has changed
488 6
                    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 13
                    $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 13
            $prevColumn = $columnName;
516 13
            $thatPosition = " AFTER " . Token::escapeIdentifier($prevColumn);
517 13
            $j++;
518
        }
519
520 13
        return $alters;
521
    }
522
523
    /**
524
     * @param CreateTable $that
525
     * @return array
526
     */
527 13
    private function diffIndexes(CreateTable $that)
528
    {
529 13
        $alters = [];
530
531 13
        foreach ($this->indexes as $indexName => $index) {
532 1
            if (!array_key_exists($indexName, $that->indexes) ||
533 1
                $index->toString() !== $that->indexes[$indexName]->toString()
534
            ) {
535
                switch ($index->type) {
536
                    case 'PRIMARY KEY':
537
                        $alter = "DROP PRIMARY KEY";
538
                        break;
539
540
                    default:
541
                        $alter = "DROP KEY " . Token::escapeIdentifier($indexName);
542
                        break;
543
                }
544 1
                $alters[] = $alter;
545
            }
546
        }
547
548 13
        foreach ($that->indexes as $indexName => $index) {
549 3
            if (!array_key_exists($indexName, $this->indexes) ||
550 3
                $index->toString() !== $this->indexes[$indexName]->toString()
551
            ) {
552 3
                $alters[] = "ADD " . $index->toString();
553
            }
554
        }
555
556 13
        return $alters;
557
    }
558
559
    /**
560
     * @param CreateTable $that
561
     * @return array
562
     */
563 13
    private function diffForeigns(CreateTable $that)
564
    {
565 13
        $alters = [];
566
567 13
        foreach ($this->foreigns as $foreignName => $foreign) {
568 1
            if (!array_key_exists($foreignName, $that->foreigns) ||
569 1
                $foreign->toString() !== $that->foreigns[$foreignName]->toString()
570
            ) {
571 1
                $alters[] = "DROP FOREIGN KEY " . Token::escapeIdentifier($foreignName);
572
            }
573
        }
574
575 13
        foreach ($that->foreigns as $foreignName => $foreign) {
576 3
            if (!array_key_exists($foreignName, $this->foreigns) ||
577 3
                $foreign->toString() !== $this->foreigns[$foreignName]->toString()
578
            ) {
579 3
                $alters[] = "ADD " . $foreign->toString();
580
            }
581
        }
582
583 13
        return $alters;
584
    }
585
586
    /**
587
     * @param CreateTable $that
588
     * @param array $flags
589
     * @return array
590
     */
591 13
    private function diffOptions(CreateTable $that, array $flags = [])
592
    {
593
        $flags += [
594 13
            'alterEngine' => true
595
        ];
596 13
        $diff = $this->options->diff($that->options, [
597 13
            'alterEngine' => $flags['alterEngine']
598
        ]);
599 13
        return ($diff == '') ? [] : [$diff];
600
    }
601
}
602