Issues (910)

console/controllers/MigrateController.php (2 issues)

1
<?php
2
/**
3
 * @link https://www.yiiframework.com/
4
 * @copyright Copyright (c) 2008 Yii Software LLC
5
 * @license https://www.yiiframework.com/license/
6
 */
7
8
namespace yii\console\controllers;
9
10
use Yii;
11
use yii\db\Connection;
12
use yii\db\Query;
13
use yii\di\Instance;
14
use yii\helpers\ArrayHelper;
15
use yii\helpers\Console;
16
use yii\helpers\Inflector;
17
18
/**
19
 * Manages application migrations.
20
 *
21
 * A migration means a set of persistent changes to the application environment
22
 * that is shared among different developers. For example, in an application
23
 * backed by a database, a migration may refer to a set of changes to
24
 * the database, such as creating a new table, adding a new table column.
25
 *
26
 * This command provides support for tracking the migration history, upgrading
27
 * or downloading with migrations, and creating new migration skeletons.
28
 *
29
 * The migration history is stored in a database table named
30
 * as [[migrationTable]]. The table will be automatically created the first time
31
 * this command is executed, if it does not exist. You may also manually
32
 * create it as follows:
33
 *
34
 * ```sql
35
 * CREATE TABLE migration (
36
 *     version varchar(180) PRIMARY KEY,
37
 *     apply_time integer
38
 * )
39
 * ```
40
 *
41
 * Below are some common usages of this command:
42
 *
43
 * ```
44
 * # creates a new migration named 'create_user_table'
45
 * yii migrate/create create_user_table
46
 *
47
 * # applies ALL new migrations
48
 * yii migrate
49
 *
50
 * # reverts the last applied migration
51
 * yii migrate/down
52
 * ```
53
 *
54
 * Since 2.0.10 you can use namespaced migrations. In order to enable this feature you should configure [[migrationNamespaces]]
55
 * property for the controller at application configuration:
56
 *
57
 * ```php
58
 * return [
59
 *     'controllerMap' => [
60
 *         'migrate' => [
61
 *             'class' => 'yii\console\controllers\MigrateController',
62
 *             'migrationNamespaces' => [
63
 *                 'app\migrations',
64
 *                 'some\extension\migrations',
65
 *             ],
66
 *             //'migrationPath' => null, // allows to disable not namespaced migration completely
67
 *         ],
68
 *     ],
69
 * ];
70
 * ```
71
 *
72
 * @author Qiang Xue <[email protected]>
73
 * @since 2.0
74
 */
75
class MigrateController extends BaseMigrateController
76
{
77
    /**
78
     * Maximum length of a migration name.
79
     * @since 2.0.13
80
     */
81
    const MAX_NAME_LENGTH = 180;
82
83
    /**
84
     * @var string the name of the table for keeping applied migration information.
85
     */
86
    public $migrationTable = '{{%migration}}';
87
    /**
88
     * {@inheritdoc}
89
     */
90
    public $templateFile = '@yii/views/migration.php';
91
    /**
92
     * @var array a set of template paths for generating migration code automatically.
93
     *
94
     * The key is the template type, the value is a path or the alias. Supported types are:
95
     * - `create_table`: table creating template
96
     * - `drop_table`: table dropping template
97
     * - `add_column`: adding new column template
98
     * - `drop_column`: dropping column template
99
     * - `create_junction`: create junction template
100
     *
101
     * @since 2.0.7
102
     */
103
    public $generatorTemplateFiles = [
104
        'create_table' => '@yii/views/createTableMigration.php',
105
        'drop_table' => '@yii/views/dropTableMigration.php',
106
        'add_column' => '@yii/views/addColumnMigration.php',
107
        'drop_column' => '@yii/views/dropColumnMigration.php',
108
        'create_junction' => '@yii/views/createTableMigration.php',
109
    ];
110
    /**
111
     * @var bool indicates whether the table names generated should consider
112
     * the `tablePrefix` setting of the DB connection. For example, if the table
113
     * name is `post` the generator wil return `{{%post}}`.
114
     * @since 2.0.8
115
     */
116
    public $useTablePrefix = true;
117
    /**
118
     * @var array column definition strings used for creating migration code.
119
     *
120
     * The format of each definition is `COLUMN_NAME:COLUMN_TYPE:COLUMN_DECORATOR`. Delimiter is `,`.
121
     * For example, `--fields="name:string(12):notNull:unique"`
122
     * produces a string column of size 12 which is not null and unique values.
123
     *
124
     * Note: primary key is added automatically and is named id by default.
125
     * If you want to use another name you may specify it explicitly like
126
     * `--fields="id_key:primaryKey,name:string(12):notNull:unique"`
127
     * @since 2.0.7
128
     */
129
    public $fields = [];
130
    /**
131
     * @var Connection|array|string the DB connection object or the application component ID of the DB connection to use
132
     * when applying migrations. Starting from version 2.0.3, this can also be a configuration array
133
     * for creating the object.
134
     */
135
    public $db = 'db';
136
    /**
137
     * @var string the comment for the table being created.
138
     * @since 2.0.14
139
     */
140
    public $comment = '';
141
142
143
    /**
144
     * {@inheritdoc}
145
     */
146 130
    public function options($actionID)
147
    {
148 130
        return array_merge(
149 130
            parent::options($actionID),
150 130
            ['migrationTable', 'db'], // global for all actions
151 130
            $actionID === 'create'
152 85
                ? ['templateFile', 'fields', 'useTablePrefix', 'comment']
153 130
                : []
154 130
        );
155
    }
156
157
    /**
158
     * {@inheritdoc}
159
     * @since 2.0.8
160
     */
161
    public function optionAliases()
162
    {
163
        return array_merge(parent::optionAliases(), [
164
            'C' => 'comment',
165
            'f' => 'fields',
166
            'p' => 'migrationPath',
167
            't' => 'migrationTable',
168
            'F' => 'templateFile',
169
            'P' => 'useTablePrefix',
170
            'c' => 'compact',
171
        ]);
172
    }
173
174
    /**
175
     * This method is invoked right before an action is to be executed (after all possible filters.)
176
     * It checks the existence of the [[migrationPath]].
177
     * @param \yii\base\Action $action the action to be executed.
178
     * @return bool whether the action should continue to be executed.
179
     */
180 141
    public function beforeAction($action)
181
    {
182 141
        if (parent::beforeAction($action)) {
183 141
            $this->db = Instance::ensure($this->db, Connection::className());
184 141
            return true;
185
        }
186
187
        return false;
188
    }
189
190
    /**
191
     * Creates a new migration instance.
192
     * @param string $class the migration class name
193
     * @return \yii\db\Migration the migration instance
194
     */
195 51
    protected function createMigration($class)
196
    {
197 51
        $this->includeMigrationFile($class);
198
199 51
        return Yii::createObject([
200 51
            'class' => $class,
201 51
            'db' => $this->db,
202 51
            'compact' => $this->compact,
203 51
        ]);
204
    }
205
206
    /**
207
     * {@inheritdoc}
208
     */
209 57
    protected function getMigrationHistory($limit)
210
    {
211 57
        if ($this->db->schema->getTableSchema($this->migrationTable, true) === null) {
212 30
            $this->createMigrationHistoryTable();
213
        }
214 57
        $query = (new Query())
215 57
            ->select(['version', 'apply_time'])
216 57
            ->from($this->migrationTable)
217 57
            ->orderBy(['apply_time' => SORT_DESC, 'version' => SORT_DESC]);
218
219 57
        if (empty($this->migrationNamespaces)) {
220 50
            $query->limit($limit);
221 50
            $rows = $query->all($this->db);
222 50
            $history = ArrayHelper::map($rows, 'version', 'apply_time');
223 50
            unset($history[self::BASE_MIGRATION]);
224 50
            return $history;
225
        }
226
227 7
        $rows = $query->all($this->db);
228
229 7
        $history = [];
230 7
        foreach ($rows as $key => $row) {
231 7
            if ($row['version'] === self::BASE_MIGRATION) {
232 7
                continue;
233
            }
234 4
            if (preg_match('/m?(\d{6}_?\d{6})(\D.*)?$/is', $row['version'], $matches)) {
235 4
                $time = str_replace('_', '', $matches[1]);
236 4
                $row['canonicalVersion'] = $time;
237
            } else {
238
                $row['canonicalVersion'] = $row['version'];
239
            }
240 4
            $row['apply_time'] = (int) $row['apply_time'];
241 4
            $history[] = $row;
242
        }
243
244 7
        usort($history, function ($a, $b) {
245 4
            if ($a['apply_time'] === $b['apply_time']) {
246 4
                if (($compareResult = strcasecmp($b['canonicalVersion'], $a['canonicalVersion'])) !== 0) {
247 2
                    return $compareResult;
248
                }
249
250 2
                return strcasecmp($b['version'], $a['version']);
251
            }
252
253 1
            return ($a['apply_time'] > $b['apply_time']) ? -1 : +1;
254 7
        });
255
256 7
        $history = array_slice($history, 0, $limit);
257
258 7
        $history = ArrayHelper::map($history, 'version', 'apply_time');
259
260 7
        return $history;
261
    }
262
263
    /**
264
     * Creates the migration history table.
265
     */
266 30
    protected function createMigrationHistoryTable()
267
    {
268 30
        $tableName = $this->db->schema->getRawTableName($this->migrationTable);
269 30
        $this->stdout("Creating migration history table \"$tableName\"...", Console::FG_YELLOW);
270 30
        $this->db->createCommand()->createTable($this->migrationTable, [
271 30
            'version' => 'varchar(' . static::MAX_NAME_LENGTH . ') NOT NULL PRIMARY KEY',
272 30
            'apply_time' => 'integer',
273 30
        ])->execute();
274 30
        $this->db->createCommand()->insert($this->migrationTable, [
275 30
            'version' => self::BASE_MIGRATION,
276 30
            'apply_time' => time(),
277 30
        ])->execute();
278 30
        $this->stdout("Done.\n", Console::FG_GREEN);
279
    }
280
281
    /**
282
     * {@inheritdoc}
283
     */
284 53
    protected function addMigrationHistory($version)
285
    {
286 53
        $command = $this->db->createCommand();
287 53
        $command->insert($this->migrationTable, [
288 53
            'version' => $version,
289 53
            'apply_time' => time(),
290 53
        ])->execute();
291
    }
292
293
    /**
294
     * {@inheritdoc}
295
     * @since 2.0.13
296
     */
297 2
    protected function truncateDatabase()
298
    {
299 2
        $db = $this->db;
300 2
        $schemas = $db->schema->getTableSchemas();
301
302
        // First drop all foreign keys,
303 2
        foreach ($schemas as $schema) {
304 2
            foreach ($schema->foreignKeys as $name => $foreignKey) {
305 1
                $db->createCommand()->dropForeignKey($name, $schema->name)->execute();
306 1
                $this->stdout("Foreign key $name dropped.\n");
307
            }
308
        }
309
310
        // Then drop the tables:
311 2
        foreach ($schemas as $schema) {
312
            try {
313 2
                $db->createCommand()->dropTable($schema->name)->execute();
314 2
                $this->stdout("Table {$schema->name} dropped.\n");
315 2
            } catch (\Exception $e) {
316 2
                if ($this->isViewRelated($e->getMessage())) {
317 2
                    $db->createCommand()->dropView($schema->name)->execute();
318 2
                    $this->stdout("View {$schema->name} dropped.\n");
319
                } else {
320
                    $this->stdout("Cannot drop {$schema->name} Table .\n");
321
                }
322
            }
323
        }
324
    }
325
326
    /**
327
     * Determines whether the error message is related to deleting a view or not
328
     * @param string $errorMessage
329
     * @return bool
330
     */
331 2
    private function isViewRelated($errorMessage)
332
    {
333 2
        $dropViewErrors = [
334 2
            'DROP VIEW to delete view', // SQLite
335 2
            'SQLSTATE[42S02]', // MySQL
336 2
        ];
337
338 2
        foreach ($dropViewErrors as $dropViewError) {
339 2
            if (strpos($errorMessage, $dropViewError) !== false) {
340 2
                return true;
341
            }
342
        }
343
344
        return false;
345
    }
346
347
    /**
348
     * {@inheritdoc}
349
     */
350 42
    protected function removeMigrationHistory($version)
351
    {
352 42
        $command = $this->db->createCommand();
353 42
        $command->delete($this->migrationTable, [
354 42
            'version' => $version,
355 42
        ])->execute();
356
    }
357
358
    private $_migrationNameLimit;
359
360
    /**
361
     * {@inheritdoc}
362
     * @since 2.0.13
363
     */
364 136
    protected function getMigrationNameLimit()
365
    {
366 136
        if ($this->_migrationNameLimit !== null) {
367 8
            return $this->_migrationNameLimit;
368
        }
369 136
        $tableSchema = $this->db->schema ? $this->db->schema->getTableSchema($this->migrationTable, true) : null;
370 136
        if ($tableSchema !== null) {
371 52
            return $this->_migrationNameLimit = $tableSchema->columns['version']->size;
372
        }
373
374 84
        return static::MAX_NAME_LENGTH;
375
    }
376
377
    /**
378
     * Normalizes table name for generator.
379
     * When name is preceded with underscore name case is kept - otherwise it's converted from camelcase to underscored.
380
     * Last underscore is always trimmed so if there should be underscore at the end of name use two of them.
381
     * @param string $name
382
     * @return string
383
     */
384 80
    private function normalizeTableName($name)
385
    {
386 80
        if (substr($name, -1) === '_') {
387 60
            $name = substr($name, 0, -1);
388
        }
389
390 80
        if (strncmp($name, '_', 1) === 0) {
391 56
            return substr($name, 1);
392
        }
393
394 26
        return Inflector::underscore($name);
395
    }
396
397
    /**
398
     * {@inheritdoc}
399
     * @since 2.0.8
400
     */
401 84
    protected function generateMigrationSourceCode($params)
402
    {
403 84
        $parsedFields = $this->parseFields();
404 84
        $fields = $parsedFields['fields'];
405 84
        $foreignKeys = $parsedFields['foreignKeys'];
406
407 84
        $name = $params['name'];
408 84
        if ($params['namespace']) {
409 82
            $name = substr($name, (strrpos($name, '\\') ?: -1) + 1);
410
        }
411
412 84
        $templateFile = $this->templateFile;
413 84
        $table = null;
414 84
        if (preg_match('/^create_?junction_?(?:table)?_?(?:for)?(.+)_?and(.+)_?tables?$/i', $name, $matches)) {
415 15
            $templateFile = $this->generatorTemplateFiles['create_junction'];
416 15
            $firstTable = $this->normalizeTableName($matches[1]);
417 15
            $secondTable = $this->normalizeTableName($matches[2]);
418
419 15
            $fields = array_merge(
420 15
                [
421 15
                    [
422 15
                        'property' => $firstTable . '_id',
423 15
                        'decorators' => 'integer()',
424 15
                    ],
425 15
                    [
426 15
                        'property' => $secondTable . '_id',
427 15
                        'decorators' => 'integer()',
428 15
                    ],
429 15
                ],
430 15
                $fields,
431 15
                [
432 15
                    [
433 15
                        'property' => 'PRIMARY KEY(' .
434 15
                            $firstTable . '_id, ' .
435 15
                            $secondTable . '_id)',
436 15
                    ],
437 15
                ]
438 15
            );
439
440 15
            $foreignKeys[$firstTable . '_id']['table'] = $firstTable;
441 15
            $foreignKeys[$secondTable . '_id']['table'] = $secondTable;
442 15
            $foreignKeys[$firstTable . '_id']['column'] = null;
443 15
            $foreignKeys[$secondTable . '_id']['column'] = null;
444 15
            $table = $firstTable . '_' . $secondTable;
445 69
        } elseif (preg_match('/^add(.+)columns?_?to(.+)table$/i', $name, $matches)) {
446 15
            $templateFile = $this->generatorTemplateFiles['add_column'];
447 15
            $table = $this->normalizeTableName($matches[2]);
448 54
        } elseif (preg_match('/^drop(.+)columns?_?from(.+)table$/i', $name, $matches)) {
449 11
            $templateFile = $this->generatorTemplateFiles['drop_column'];
450 11
            $table = $this->normalizeTableName($matches[2]);
451 43
        } elseif (preg_match('/^create(.+)table$/i', $name, $matches)) {
452 27
            $this->addDefaultPrimaryKey($fields);
453 27
            $templateFile = $this->generatorTemplateFiles['create_table'];
454 27
            $table = $this->normalizeTableName($matches[1]);
455 16
        } elseif (preg_match('/^drop(.+)table$/i', $name, $matches)) {
456 12
            $this->addDefaultPrimaryKey($fields);
457 12
            $templateFile = $this->generatorTemplateFiles['drop_table'];
458 12
            $table = $this->normalizeTableName($matches[1]);
459
        }
460
461 84
        foreach ($foreignKeys as $column => $foreignKey) {
462 23
            $relatedColumn = $foreignKey['column'];
463 23
            $relatedTable = $foreignKey['table'];
464
            // Since 2.0.11 if related column name is not specified,
465
            // we're trying to get it from table schema
466
            // @see https://github.com/yiisoft/yii2/issues/12748
467 23
            if ($relatedColumn === null) {
468 23
                $relatedColumn = 'id';
469
                try {
470 23
                    $this->db = Instance::ensure($this->db, Connection::className());
471 23
                    $relatedTableSchema = $this->db->getTableSchema($relatedTable);
472 23
                    if ($relatedTableSchema !== null) {
473
                        $primaryKeyCount = count($relatedTableSchema->primaryKey);
474
                        if ($primaryKeyCount === 1) {
475
                            $relatedColumn = $relatedTableSchema->primaryKey[0];
476
                        } elseif ($primaryKeyCount > 1) {
477
                            $this->stdout("Related table for field \"{$column}\" exists, but primary key is composite. Default name \"id\" will be used for related field\n", Console::FG_YELLOW);
478
                        } elseif ($primaryKeyCount === 0) {
479 23
                            $this->stdout("Related table for field \"{$column}\" exists, but does not have a primary key. Default name \"id\" will be used for related field.\n", Console::FG_YELLOW);
480
                        }
481
                    }
482
                } catch (\ReflectionException $e) {
483
                    $this->stdout("Cannot initialize database component to try reading referenced table schema for field \"{$column}\". Default name \"id\" will be used for related field.\n", Console::FG_YELLOW);
484
                }
485
            }
486 23
            $foreignKeys[$column] = [
487 23
                'idx' => $this->generateTableName("idx-$table-$column"),
488 23
                'fk' => $this->generateTableName("fk-$table-$column"),
489 23
                'relatedTable' => $this->generateTableName($relatedTable),
490 23
                'relatedColumn' => $relatedColumn,
491 23
            ];
492
        }
493
494 84
        return $this->renderFile(Yii::getAlias($templateFile), array_merge($params, [
0 ignored issues
show
It seems like Yii::getAlias($templateFile) can also be of type false; however, parameter $file of yii\base\Controller::renderFile() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

494
        return $this->renderFile(/** @scrutinizer ignore-type */ Yii::getAlias($templateFile), array_merge($params, [
Loading history...
495 84
            'table' => $this->generateTableName($table),
496 84
            'fields' => $fields,
497 84
            'foreignKeys' => $foreignKeys,
498 84
            'tableComment' => $this->comment,
499 84
        ]));
500
    }
501
502
    /**
503
     * If `useTablePrefix` equals true, then the table name will contain the
504
     * prefix format.
505
     *
506
     * @param string $tableName the table name to generate.
507
     * @return string
508
     * @since 2.0.8
509
     */
510 84
    protected function generateTableName($tableName)
511
    {
512 84
        if (!$this->useTablePrefix) {
513
            return $tableName;
514
        }
515
516 84
        return '{{%' . $tableName . '}}';
517
    }
518
519
    /**
520
     * Parse the command line migration fields.
521
     * @return array parse result with following fields:
522
     *
523
     * - fields: array, parsed fields
524
     * - foreignKeys: array, detected foreign keys
525
     *
526
     * @since 2.0.7
527
     */
528 84
    protected function parseFields()
529
    {
530 84
        $fields = [];
531 84
        $foreignKeys = [];
532
533 84
        foreach ($this->fields as $index => $field) {
534 46
            $chunks = $this->splitFieldIntoChunks($field);
535 46
            $property = array_shift($chunks);
0 ignored issues
show
It seems like $chunks can also be of type false; however, parameter $array of array_shift() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

535
            $property = array_shift(/** @scrutinizer ignore-type */ $chunks);
Loading history...
536
537 46
            foreach ($chunks as $i => &$chunk) {
538 46
                if (strncmp($chunk, 'foreignKey', 10) === 0) {
539 8
                    preg_match('/foreignKey\((\w*)\s?(\w*)\)/', $chunk, $matches);
540 8
                    $foreignKeys[$property] = [
541 8
                        'table' => isset($matches[1])
542 8
                            ? $matches[1]
543 8
                            : preg_replace('/_id$/', '', $property),
544 8
                        'column' => !empty($matches[2])
545
                            ? $matches[2]
546
                            : null,
547 8
                    ];
548
549 8
                    unset($chunks[$i]);
550 8
                    continue;
551
                }
552
553 46
                if (!preg_match('/^(.+?)\(([^(]+)\)$/', $chunk)) {
554 46
                    $chunk .= '()';
555
                }
556
            }
557 46
            $fields[] = [
558 46
                'property' => $property,
559 46
                'decorators' => implode('->', $chunks),
560 46
            ];
561
        }
562
563 84
        return [
564 84
            'fields' => $fields,
565 84
            'foreignKeys' => $foreignKeys,
566 84
        ];
567
    }
568
569
    /**
570
     * Splits field into chunks
571
     *
572
     * @param string $field
573
     * @return string[]|false
574
     */
575 46
    protected function splitFieldIntoChunks($field)
576
    {
577 46
        $originalDefaultValue = null;
578 46
        $defaultValue = null;
579 46
        preg_match_all('/defaultValue\(["\'].*?:?.*?["\']\)/', $field, $matches, PREG_SET_ORDER, 0);
580 46
        if (isset($matches[0][0])) {
581 5
            $originalDefaultValue = $matches[0][0];
582 5
            $defaultValue = str_replace(':', '{{colon}}', $originalDefaultValue);
583 5
            $field = str_replace($originalDefaultValue, $defaultValue, $field);
584
        }
585
586 46
        $chunks = preg_split('/\s?:\s?/', $field);
587
588 46
        if (is_array($chunks) && $defaultValue !== null && $originalDefaultValue !== null) {
589 5
            foreach ($chunks as $key => $chunk) {
590 5
                $chunks[$key] = str_replace($defaultValue, $originalDefaultValue, $chunk);
591
            }
592
        }
593
594 46
        return $chunks;
595
    }
596
597
    /**
598
     * Adds default primary key to fields list if there's no primary key specified.
599
     * @param array $fields parsed fields
600
     * @since 2.0.7
601
     */
602 39
    protected function addDefaultPrimaryKey(&$fields)
603
    {
604 39
        foreach ($fields as $field) {
605 20
            if ($field['property'] === 'id' || false !== strripos($field['decorators'], 'primarykey()')) {
606 10
                return;
607
            }
608
        }
609 29
        array_unshift($fields, ['property' => 'id', 'decorators' => 'primaryKey()']);
610
    }
611
}
612