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.

CreateTable::setAddIndexForForeignKey()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

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