Passed
Push — master ( 9dbdd9...d5a428 )
by Alexander
04:15
created

console/controllers/MigrateController.php (1 issue)

1
<?php
2
/**
3
 * @link http://www.yiiframework.com/
4
 * @copyright Copyright (c) 2008 Yii Software LLC
5
 * @license http://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 149
    public function options($actionID)
147
    {
148 149
        return array_merge(
149 149
            parent::options($actionID),
150 149
            ['migrationTable', 'db'], // global for all actions
151 149
            $actionID === 'create'
152 84
                ? ['templateFile', 'fields', 'useTablePrefix', 'comment']
153 149
                : []
154
        );
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 160
    public function beforeAction($action)
181
    {
182 160
        if (parent::beforeAction($action)) {
183 160
            $this->db = Instance::ensure($this->db, Connection::className());
184 160
            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 71
    protected function createMigration($class)
196
    {
197 71
        $this->includeMigrationFile($class);
198
199 71
        return Yii::createObject([
200 71
            'class' => $class,
201 71
            'db' => $this->db,
202 71
            'compact' => $this->compact,
203
        ]);
204
    }
205
206
    /**
207
     * {@inheritdoc}
208
     */
209 77
    protected function getMigrationHistory($limit)
210
    {
211 77
        if ($this->db->schema->getTableSchema($this->migrationTable, true) === null) {
212 30
            $this->createMigrationHistoryTable();
213
        }
214 77
        $query = (new Query())
215 77
            ->select(['version', 'apply_time'])
216 77
            ->from($this->migrationTable)
217 77
            ->orderBy(['apply_time' => SORT_DESC, 'version' => SORT_DESC]);
218
219 77
        if (empty($this->migrationNamespaces)) {
220 70
            $query->limit($limit);
221 70
            $rows = $query->all($this->db);
222 70
            $history = ArrayHelper::map($rows, 'version', 'apply_time');
223 70
            unset($history[self::BASE_MIGRATION]);
224 70
            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 2
            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 30
    }
280
281
    /**
282
     * {@inheritdoc}
283
     */
284 73
    protected function addMigrationHistory($version)
285
    {
286 73
        $command = $this->db->createCommand();
287 73
        $command->insert($this->migrationTable, [
288 73
            'version' => $version,
289 73
            'apply_time' => time(),
290 73
        ])->execute();
291 73
    }
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 2
            }
308
        }
309
310
        // Then drop the tables:
311
        foreach ($schemas as $schema) {
312
            try {
313 2
                $db->createCommand()->dropTable($schema->name)->execute();
314
                $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 2
                } else {
320 2
                    $this->stdout("Cannot drop {$schema->name} Table .\n");
321
                }
322 2
            }
323
        }
324
    }
325
326 2
    /**
327
     * Determines whether the error message is related to deleting a view or not
328
     * @param string $errorMessage
329
     * @return bool
330
     */
331
    private function isViewRelated($errorMessage)
332
    {
333 2
        $dropViewErrors = [
334
            'DROP VIEW to delete view', // SQLite
335
            'SQLSTATE[42S02]', // MySQL
336 2
        ];
337
338
        foreach ($dropViewErrors as $dropViewError) {
339
            if (strpos($errorMessage, $dropViewError) !== false) {
340 2
                return true;
341 2
            }
342 2
        }
343
344
        return false;
345
    }
346
347
    /**
348
     * {@inheritdoc}
349
     */
350
    protected function removeMigrationHistory($version)
351
    {
352 62
        $command = $this->db->createCommand();
353
        $command->delete($this->migrationTable, [
354 62
            'version' => $version,
355 62
        ])->execute();
356 62
    }
357 62
358 62
    private $_migrationNameLimit;
359
360
    /**
361
     * {@inheritdoc}
362
     * @since 2.0.13
363
     */
364
    protected function getMigrationNameLimit()
365
    {
366 155
        if ($this->_migrationNameLimit !== null) {
367
            return $this->_migrationNameLimit;
368 155
        }
369 8
        $tableSchema = $this->db->schema ? $this->db->schema->getTableSchema($this->migrationTable, true) : null;
370
        if ($tableSchema !== null) {
371 155
            return $this->_migrationNameLimit = $tableSchema->columns['version']->size;
372 155
        }
373 72
374
        return static::MAX_NAME_LENGTH;
375
    }
376 83
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
    private function normalizeTableName($name)
385
    {
386 79
        if (substr($name, -1) === '_') {
387
            $name = substr($name, 0, -1);
388 79
        }
389 59
390
        if (strpos($name, '_') === 0) {
391
            return substr($name, 1);
392 79
        }
393 55
394
        return Inflector::underscore($name);
395
    }
396 26
397
    /**
398
     * {@inheritdoc}
399
     * @since 2.0.8
400
     */
401
    protected function generateMigrationSourceCode($params)
402
    {
403 83
        $parsedFields = $this->parseFields();
404
        $fields = $parsedFields['fields'];
405 83
        $foreignKeys = $parsedFields['foreignKeys'];
406 83
407 83
        $name = $params['name'];
408
        if ($params['namespace']) {
409 83
            $name = substr($name, strrpos($name, '\\') + 1);
410 83
        }
411 81
412
        $templateFile = $this->templateFile;
413
        $table = null;
414 83
        if (preg_match(
415 83
            '/^create_?junction_?(?:table)?_?(?:for)?(.+)_?and(.+)_?tables?$/i',
416 83
            $name,
417 83
            $matches
418 83
        )) {
419 83
            $templateFile = $this->generatorTemplateFiles['create_junction'];
420
            $firstTable = $this->normalizeTableName($matches[1]);
421 15
            $secondTable = $this->normalizeTableName($matches[2]);
422 15
423 15
            $fields = array_merge(
424
                [
425 15
                    [
426
                        'property' => $firstTable . '_id',
427
                        'decorators' => 'integer()',
428 15
                    ],
429 15
                    [
430
                        'property' => $secondTable . '_id',
431
                        'decorators' => 'integer()',
432 15
                    ],
433 15
                ],
434
                $fields,
435
                [
436 15
                    [
437
                        'property' => 'PRIMARY KEY(' .
438
                            $firstTable . '_id, ' .
439
                            $secondTable . '_id)',
440 15
                    ],
441 15
                ]
442
            );
443
444
            $foreignKeys[$firstTable . '_id']['table'] = $firstTable;
445
            $foreignKeys[$secondTable . '_id']['table'] = $secondTable;
446 15
            $foreignKeys[$firstTable . '_id']['column'] = null;
447 15
            $foreignKeys[$secondTable . '_id']['column'] = null;
448 15
            $table = $firstTable . '_' . $secondTable;
449 15
        } elseif (preg_match('/^add(.+)columns?_?to(.+)table$/i', $name, $matches)) {
450 15
            $templateFile = $this->generatorTemplateFiles['add_column'];
451 68
            $table = $this->normalizeTableName($matches[2]);
452 15
        } elseif (preg_match('/^drop(.+)columns?_?from(.+)table$/i', $name, $matches)) {
453 15
            $templateFile = $this->generatorTemplateFiles['drop_column'];
454 53
            $table = $this->normalizeTableName($matches[2]);
455 11
        } elseif (preg_match('/^create(.+)table$/i', $name, $matches)) {
456 11
            $this->addDefaultPrimaryKey($fields);
457 42
            $templateFile = $this->generatorTemplateFiles['create_table'];
458 26
            $table = $this->normalizeTableName($matches[1]);
459 26
        } elseif (preg_match('/^drop(.+)table$/i', $name, $matches)) {
460 26
            $this->addDefaultPrimaryKey($fields);
461 16
            $templateFile = $this->generatorTemplateFiles['drop_table'];
462 12
            $table = $this->normalizeTableName($matches[1]);
463 12
        }
464 12
465
        foreach ($foreignKeys as $column => $foreignKey) {
466
            $relatedColumn = $foreignKey['column'];
467 83
            $relatedTable = $foreignKey['table'];
468 23
            // Since 2.0.11 if related column name is not specified,
469 23
            // we're trying to get it from table schema
470
            // @see https://github.com/yiisoft/yii2/issues/12748
471
            if ($relatedColumn === null) {
472
                $relatedColumn = 'id';
473 23
                try {
474 23
                    $this->db = Instance::ensure($this->db, Connection::className());
0 ignored issues
show
Deprecated Code introduced by
The function yii\base\BaseObject::className() has been deprecated: since 2.0.14. On PHP >=5.5, use `::class` instead. ( Ignorable by Annotation )

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

474
                    $this->db = Instance::ensure($this->db, /** @scrutinizer ignore-deprecated */ Connection::className());

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
475
                    $relatedTableSchema = $this->db->getTableSchema($relatedTable);
476 23
                    if ($relatedTableSchema !== null) {
477 23
                        $primaryKeyCount = count($relatedTableSchema->primaryKey);
478 23
                        if ($primaryKeyCount === 1) {
479
                            $relatedColumn = $relatedTableSchema->primaryKey[0];
480
                        } elseif ($primaryKeyCount > 1) {
481
                            $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);
482
                        } elseif ($primaryKeyCount === 0) {
483
                            $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);
484
                        }
485 23
                    }
486
                } catch (\ReflectionException $e) {
487
                    $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);
488
                }
489
            }
490
            $foreignKeys[$column] = [
491
                'idx' => $this->generateTableName("idx-$table-$column"),
492 23
                'fk' => $this->generateTableName("fk-$table-$column"),
493 23
                'relatedTable' => $this->generateTableName($relatedTable),
494 23
                'relatedColumn' => $relatedColumn,
495 23
            ];
496 23
        }
497
498
        return $this->renderFile(Yii::getAlias($templateFile), array_merge($params, [
499
            'table' => $this->generateTableName($table),
500 83
            'fields' => $fields,
501 83
            'foreignKeys' => $foreignKeys,
502 83
            'tableComment' => $this->comment,
503 83
        ]));
504 83
    }
505
506
    /**
507
     * If `useTablePrefix` equals true, then the table name will contain the
508
     * prefix format.
509
     *
510
     * @param string $tableName the table name to generate.
511
     * @return string
512
     * @since 2.0.8
513
     */
514
    protected function generateTableName($tableName)
515
    {
516 83
        if (!$this->useTablePrefix) {
517
            return $tableName;
518 83
        }
519
520
        return '{{%' . $tableName . '}}';
521
    }
522 83
523
    /**
524
     * Parse the command line migration fields.
525
     * @return array parse result with following fields:
526
     *
527
     * - fields: array, parsed fields
528
     * - foreignKeys: array, detected foreign keys
529
     *
530
     * @since 2.0.7
531
     */
532
    protected function parseFields()
533
    {
534 83
        $fields = [];
535
        $foreignKeys = [];
536 83
537 83
        foreach ($this->fields as $index => $field) {
538
            $chunks = $this->splitFieldIntoChunks($field);
539 83
            $property = array_shift($chunks);
540 45
541 45
            foreach ($chunks as $i => &$chunk) {
542
                if (strncmp($chunk, 'foreignKey', 10) === 0) {
543 45
                    preg_match('/foreignKey\((\w*)\s?(\w*)\)/', $chunk, $matches);
544 45
                    $foreignKeys[$property] = [
545 8
                        'table' => isset($matches[1])
546 8
                            ? $matches[1]
547 8
                            : preg_replace('/_id$/', '', $property),
548 8
                        'column' => !empty($matches[2])
549 8
                            ? $matches[2]
550 8
                            : null,
551
                    ];
552
553
                    unset($chunks[$i]);
554
                    continue;
555 8
                }
556 8
557
                if (!preg_match('/^(.+?)\(([^(]+)\)$/', $chunk)) {
558
                    $chunk .= '()';
559 45
                }
560 45
            }
561
            $fields[] = [
562
                'property' => $property,
563 45
                'decorators' => implode('->', $chunks),
564 45
            ];
565 45
        }
566
567
        return [
568
            'fields' => $fields,
569
            'foreignKeys' => $foreignKeys,
570 83
        ];
571 83
    }
572
573
    /**
574
     * Splits field into chunks
575
     *
576
     * @param string $field
577
     * @return string[]|false
578
     */
579
    protected function splitFieldIntoChunks($field)
580
    {
581 45
        $originalDefaultValue = null;
582
        $defaultValue = null;
583 45
        preg_match_all('/defaultValue\(["\'].*?:?.*?["\']\)/', $field, $matches, PREG_SET_ORDER, 0);
584 45
        if (isset($matches[0][0])) {
585 45
            $originalDefaultValue = $matches[0][0];
586 5
            $defaultValue = str_replace(':', '{{colon}}', $originalDefaultValue);
587 5
            $field = str_replace($originalDefaultValue, $defaultValue, $field);
588 5
        }
589 5
590
        $chunks = preg_split('/\s?:\s?/', $field);
591
592 45
        if (is_array($chunks) && $defaultValue !== null && $originalDefaultValue !== null) {
593
            foreach ($chunks as $key => $chunk) {
594 45
                $chunks[$key] = str_replace($defaultValue, $originalDefaultValue, $chunk);
595 5
            }
596 5
        }
597
598
        return $chunks;
599
    }
600 45
601
    /**
602
     * Adds default primary key to fields list if there's no primary key specified.
603
     * @param array $fields parsed fields
604
     * @since 2.0.7
605
     */
606
    protected function addDefaultPrimaryKey(&$fields)
607
    {
608 38
        foreach ($fields as $field) {
609
            if (false !== strripos($field['decorators'], 'primarykey()')) {
610 38
                return;
611 19
            }
612 19
        }
613
        array_unshift($fields, ['property' => 'id', 'decorators' => 'primaryKey()']);
614
    }
615
}
616