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
06:24
created

CreateTable::diffForeigns()   B

Complexity

Conditions 7
Paths 9

Size

Total Lines 21
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 56

Importance

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