Completed
Push — master ( 55b06d...9f2a87 )
by Alexander
35:57
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
17
/**
18
 * Manages application migrations.
19
 *
20
 * A migration means a set of persistent changes to the application environment
21
 * that is shared among different developers. For example, in an application
22
 * backed by a database, a migration may refer to a set of changes to
23
 * the database, such as creating a new table, adding a new table column.
24
 *
25
 * This command provides support for tracking the migration history, upgrading
26
 * or downloading with migrations, and creating new migration skeletons.
27
 *
28
 * The migration history is stored in a database table named
29
 * as [[migrationTable]]. The table will be automatically created the first time
30
 * this command is executed, if it does not exist. You may also manually
31
 * create it as follows:
32
 *
33
 * ```sql
34
 * CREATE TABLE migration (
35
 *     version varchar(180) PRIMARY KEY,
36
 *     apply_time integer
37
 * )
38
 * ```
39
 *
40
 * Below are some common usages of this command:
41
 *
42
 * ```
43
 * # creates a new migration named 'create_user_table'
44
 * yii migrate/create create_user_table
45
 *
46
 * # applies ALL new migrations
47
 * yii migrate
48
 *
49
 * # reverts the last applied migration
50
 * yii migrate/down
51
 * ```
52
 *
53
 * Since 2.0.10 you can use namespaced migrations. In order to enable this feature you should configure [[migrationNamespaces]]
54
 * property for the controller at application configuration:
55
 *
56
 * ```php
57
 * return [
58
 *     'controllerMap' => [
59
 *         'migrate' => [
60
 *             'class' => 'yii\console\controllers\MigrateController',
61
 *             'migrationNamespaces' => [
62
 *                 'app\migrations',
63
 *                 'some\extension\migrations',
64
 *             ],
65
 *             //'migrationPath' => null, // allows to disable not namespaced migration completely
66
 *         ],
67
 *     ],
68
 * ];
69
 * ```
70
 *
71
 * @author Qiang Xue <[email protected]>
72
 * @since 2.0
73
 */
74
class MigrateController extends BaseMigrateController
75
{
76
    /**
77
     * Maximum length of a migration name.
78
     * @since 2.0.13
79
     */
80
    const MAX_NAME_LENGTH = 180;
81
82
    /**
83
     * @var string the name of the table for keeping applied migration information.
84
     */
85
    public $migrationTable = '{{%migration}}';
86
    /**
87
     * {@inheritdoc}
88
     */
89
    public $templateFile = '@yii/views/migration.php';
90
    /**
91
     * @var array a set of template paths for generating migration code automatically.
92
     *
93
     * The key is the template type, the value is a path or the alias. Supported types are:
94
     * - `create_table`: table creating template
95
     * - `drop_table`: table dropping template
96
     * - `add_column`: adding new column template
97
     * - `drop_column`: dropping column template
98
     * - `create_junction`: create junction template
99
     *
100
     * @since 2.0.7
101
     */
102
    public $generatorTemplateFiles = [
103
        'create_table' => '@yii/views/createTableMigration.php',
104
        'drop_table' => '@yii/views/dropTableMigration.php',
105
        'add_column' => '@yii/views/addColumnMigration.php',
106
        'drop_column' => '@yii/views/dropColumnMigration.php',
107
        'create_junction' => '@yii/views/createTableMigration.php',
108
    ];
109
    /**
110
     * @var bool indicates whether the table names generated should consider
111
     * the `tablePrefix` setting of the DB connection. For example, if the table
112
     * name is `post` the generator wil return `{{%post}}`.
113
     * @since 2.0.8
114
     */
115
    public $useTablePrefix = true;
116
    /**
117
     * @var array column definition strings used for creating migration code.
118
     *
119
     * The format of each definition is `COLUMN_NAME:COLUMN_TYPE:COLUMN_DECORATOR`. Delimiter is `,`.
120
     * For example, `--fields="name:string(12):notNull:unique"`
121
     * produces a string column of size 12 which is not null and unique values.
122
     *
123
     * Note: primary key is added automatically and is named id by default.
124
     * If you want to use another name you may specify it explicitly like
125
     * `--fields="id_key:primaryKey,name:string(12):notNull:unique"`
126
     * @since 2.0.7
127
     */
128
    public $fields = [];
129
    /**
130
     * @var Connection|array|string the DB connection object or the application component ID of the DB connection to use
131
     * when applying migrations. Starting from version 2.0.3, this can also be a configuration array
132
     * for creating the object.
133
     */
134
    public $db = 'db';
135
    /**
136
     * @var string the comment for the table being created.
137
     * @since 2.0.14
138
     */
139
    public $comment = '';
140
141
142
    /**
143
     * {@inheritdoc}
144
     */
145 57
    public function options($actionID)
146
    {
147 57
        return array_merge(
148 57
            parent::options($actionID),
149 57
            ['migrationTable', 'db'], // global for all actions
150 57
            $actionID === 'create'
151 10
                ? ['templateFile', 'fields', 'useTablePrefix', 'comment']
152 57
                : []
153
        );
154
    }
155
156
    /**
157
     * {@inheritdoc}
158
     * @since 2.0.8
159
     */
160
    public function optionAliases()
161
    {
162
        return array_merge(parent::optionAliases(), [
163
            'C' => 'comment',
164
            'f' => 'fields',
165
            'p' => 'migrationPath',
166
            't' => 'migrationTable',
167
            'F' => 'templateFile',
168
            'P' => 'useTablePrefix',
169
            'c' => 'compact',
170
        ]);
171
    }
172
173
    /**
174
     * This method is invoked right before an action is to be executed (after all possible filters.)
175
     * It checks the existence of the [[migrationPath]].
176
     * @param \yii\base\Action $action the action to be executed.
177
     * @return bool whether the action should continue to be executed.
178
     */
179 68
    public function beforeAction($action)
180
    {
181 68
        if (parent::beforeAction($action)) {
182 68
            $this->db = Instance::ensure($this->db, Connection::className());
183 68
            return true;
184
        }
185
186
        return false;
187
    }
188
189
    /**
190
     * Creates a new migration instance.
191
     * @param string $class the migration class name
192
     * @return \yii\db\Migration the migration instance
193
     */
194 53
    protected function createMigration($class)
195
    {
196 53
        $this->includeMigrationFile($class);
197
198 53
        return Yii::createObject([
199 53
            'class' => $class,
200 53
            'db' => $this->db,
201 53
            'compact' => $this->compact,
202
        ]);
203
    }
204
205
    /**
206
     * {@inheritdoc}
207
     */
208 59
    protected function getMigrationHistory($limit)
209
    {
210 59
        if ($this->db->schema->getTableSchema($this->migrationTable, true) === null) {
211 29
            $this->createMigrationHistoryTable();
212
        }
213 59
        $query = (new Query())
214 59
            ->select(['version', 'apply_time'])
215 59
            ->from($this->migrationTable)
216 59
            ->orderBy(['apply_time' => SORT_DESC, 'version' => SORT_DESC]);
217
218 59
        if (empty($this->migrationNamespaces)) {
219 52
            $query->limit($limit);
220 52
            $rows = $query->all($this->db);
221 52
            $history = ArrayHelper::map($rows, 'version', 'apply_time');
222 52
            unset($history[self::BASE_MIGRATION]);
223 52
            return $history;
224
        }
225
226 7
        $rows = $query->all($this->db);
227
228 7
        $history = [];
229 7
        foreach ($rows as $key => $row) {
230 7
            if ($row['version'] === self::BASE_MIGRATION) {
231 7
                continue;
232
            }
233 4
            if (preg_match('/m?(\d{6}_?\d{6})(\D.*)?$/is', $row['version'], $matches)) {
234 4
                $time = str_replace('_', '', $matches[1]);
235 4
                $row['canonicalVersion'] = $time;
236
            } else {
237
                $row['canonicalVersion'] = $row['version'];
238
            }
239 4
            $row['apply_time'] = (int) $row['apply_time'];
240 4
            $history[] = $row;
241
        }
242
243 7
        usort($history, function ($a, $b) {
244 4
            if ($a['apply_time'] === $b['apply_time']) {
245 4
                if (($compareResult = strcasecmp($b['canonicalVersion'], $a['canonicalVersion'])) !== 0) {
246 2
                    return $compareResult;
247
                }
248
249 2
                return strcasecmp($b['version'], $a['version']);
250
            }
251
252 1
            return ($a['apply_time'] > $b['apply_time']) ? -1 : +1;
253 7
        });
254
255 7
        $history = array_slice($history, 0, $limit);
256
257 7
        $history = ArrayHelper::map($history, 'version', 'apply_time');
258
259 7
        return $history;
260
    }
261
262
    /**
263
     * Creates the migration history table.
264
     */
265 29
    protected function createMigrationHistoryTable()
266
    {
267 29
        $tableName = $this->db->schema->getRawTableName($this->migrationTable);
268 29
        $this->stdout("Creating migration history table \"$tableName\"...", Console::FG_YELLOW);
269 29
        $this->db->createCommand()->createTable($this->migrationTable, [
270 29
            'version' => 'varchar(' . static::MAX_NAME_LENGTH . ') NOT NULL PRIMARY KEY',
271 29
            'apply_time' => 'integer',
272 29
        ])->execute();
273 29
        $this->db->createCommand()->insert($this->migrationTable, [
274 29
            'version' => self::BASE_MIGRATION,
275 29
            'apply_time' => time(),
276 29
        ])->execute();
277 29
        $this->stdout("Done.\n", Console::FG_GREEN);
278 29
    }
279
280
    /**
281
     * {@inheritdoc}
282
     */
283 55
    protected function addMigrationHistory($version)
284
    {
285 55
        $command = $this->db->createCommand();
286 55
        $command->insert($this->migrationTable, [
287 55
            'version' => $version,
288 55
            'apply_time' => time(),
289 55
        ])->execute();
290 55
    }
291
292
    /**
293
     * {@inheritdoc}
294
     * @since 2.0.13
295
     */
296 2
    protected function truncateDatabase()
297
    {
298 2
        $db = $this->db;
299 2
        $schemas = $db->schema->getTableSchemas();
300
301
        // First drop all foreign keys,
302 2
        foreach ($schemas as $schema) {
303 2
            if ($schema->foreignKeys) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $schema->foreignKeys of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
304 1
                foreach ($schema->foreignKeys as $name => $foreignKey) {
305 1
                    $db->createCommand()->dropForeignKey($name, $schema->name)->execute();
306 2
                    $this->stdout("Foreign key $name dropped.\n");
307
                }
308
            }
309
        }
310
311
        // Then drop the tables:
312 2
        foreach ($schemas as $schema) {
313
            try {
314 2
                $db->createCommand()->dropTable($schema->name)->execute();
315 2
                $this->stdout("Table {$schema->name} dropped.\n");
316 2
            } catch (\Exception $e) {
317 2
                if ($this->isViewRelated($e->getMessage())) {
318 2
                    $db->createCommand()->dropView($schema->name)->execute();
319 2
                    $this->stdout("View {$schema->name} dropped.\n");
320
                } else {
321 2
                    $this->stdout("Cannot drop {$schema->name} Table .\n");
322
                }
323
            }
324
        }
325 2
    }
326
327
    /**
328
     * Determines whether the error message is related to deleting a view or not
329
     * @param string $errorMessage
330
     * @return bool
331
     */
332 2
    private function isViewRelated($errorMessage)
333
    {
334
        $dropViewErrors = [
335 2
            'DROP VIEW to delete view', // SQLite
336
            'SQLSTATE[42S02]', // MySQL
337
        ];
338
339 2
        foreach ($dropViewErrors as $dropViewError) {
340 2
            if (strpos($errorMessage, $dropViewError) !== false) {
341 2
                return true;
342
            }
343
        }
344
345
        return false;
346
    }
347
348
    /**
349
     * {@inheritdoc}
350
     */
351 44
    protected function removeMigrationHistory($version)
352
    {
353 44
        $command = $this->db->createCommand();
354 44
        $command->delete($this->migrationTable, [
355 44
            'version' => $version,
356 44
        ])->execute();
357 44
    }
358
359
    private $_migrationNameLimit;
360
361
    /**
362
     * {@inheritdoc}
363
     * @since 2.0.13
364
     */
365 63
    protected function getMigrationNameLimit()
366
    {
367 63
        if ($this->_migrationNameLimit !== null) {
368 8
            return $this->_migrationNameLimit;
369
        }
370 63
        $tableSchema = $this->db->schema ? $this->db->schema->getTableSchema($this->migrationTable, true) : null;
371 63
        if ($tableSchema !== null) {
372 54
            return $this->_migrationNameLimit = $tableSchema->columns['version']->size;
373
        }
374
375 9
        return static::MAX_NAME_LENGTH;
376
    }
377
378
    /**
379
     * {@inheritdoc}
380
     * @since 2.0.8
381
     */
382 9
    protected function generateMigrationSourceCode($params)
383
    {
384 9
        $parsedFields = $this->parseFields();
385 9
        $fields = $parsedFields['fields'];
386 9
        $foreignKeys = $parsedFields['foreignKeys'];
387
388 9
        $name = $params['name'];
389
390 9
        $templateFile = $this->templateFile;
391 9
        $table = null;
392 9
        if (preg_match('/^create_junction(?:_table_for_|_for_|_)(.+)_and_(.+)_tables?$/', $name, $matches)) {
393 1
            $templateFile = $this->generatorTemplateFiles['create_junction'];
394 1
            $firstTable = $matches[1];
395 1
            $secondTable = $matches[2];
396
397 1
            $fields = array_merge(
398
                [
399
                    [
400 1
                        'property' => $firstTable . '_id',
401 1
                        'decorators' => 'integer()',
402
                    ],
403
                    [
404 1
                        'property' => $secondTable . '_id',
405 1
                        'decorators' => 'integer()',
406
                    ],
407
                ],
408 1
                $fields,
409
                [
410
                    [
411
                        'property' => 'PRIMARY KEY(' .
412 1
                            $firstTable . '_id, ' .
413 1
                            $secondTable . '_id)',
414
                    ],
415
                ]
416
            );
417
418 1
            $foreignKeys[$firstTable . '_id']['table'] = $firstTable;
419 1
            $foreignKeys[$secondTable . '_id']['table'] = $secondTable;
420 1
            $foreignKeys[$firstTable . '_id']['column'] = null;
421 1
            $foreignKeys[$secondTable . '_id']['column'] = null;
422 1
            $table = $firstTable . '_' . $secondTable;
423 8
        } elseif (preg_match('/^add_(.+)_columns?_to_(.+)_table$/', $name, $matches)) {
424 1
            $templateFile = $this->generatorTemplateFiles['add_column'];
425 1
            $table = $matches[2];
426 7
        } elseif (preg_match('/^drop_(.+)_columns?_from_(.+)_table$/', $name, $matches)) {
427 1
            $templateFile = $this->generatorTemplateFiles['drop_column'];
428 1
            $table = $matches[2];
429 6
        } elseif (preg_match('/^create_(.+)_table$/', $name, $matches)) {
430 1
            $this->addDefaultPrimaryKey($fields);
431 1
            $templateFile = $this->generatorTemplateFiles['create_table'];
432 1
            $table = $matches[1];
433 6
        } elseif (preg_match('/^drop_(.+)_table$/', $name, $matches)) {
434 2
            $this->addDefaultPrimaryKey($fields);
435 2
            $templateFile = $this->generatorTemplateFiles['drop_table'];
436 2
            $table = $matches[1];
437
        }
438
439 9
        foreach ($foreignKeys as $column => $foreignKey) {
440 3
            $relatedColumn = $foreignKey['column'];
441 3
            $relatedTable = $foreignKey['table'];
442
            // Since 2.0.11 if related column name is not specified,
443
            // we're trying to get it from table schema
444
            // @see https://github.com/yiisoft/yii2/issues/12748
445 3
            if ($relatedColumn === null) {
446 3
                $relatedColumn = 'id';
447
                try {
448 3
                    $this->db = Instance::ensure($this->db, Connection::className());
449 3
                    $relatedTableSchema = $this->db->getTableSchema($relatedTable);
450 3
                    if ($relatedTableSchema !== null) {
451
                        $primaryKeyCount = count($relatedTableSchema->primaryKey);
452
                        if ($primaryKeyCount === 1) {
453
                            $relatedColumn = $relatedTableSchema->primaryKey[0];
454
                        } elseif ($primaryKeyCount > 1) {
455
                            $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);
456
                        } elseif ($primaryKeyCount === 0) {
457 3
                            $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);
458
                        }
459
                    }
460
                } catch (\ReflectionException $e) {
461
                    $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);
462
                }
463
            }
464 3
            $foreignKeys[$column] = [
465 3
                'idx' => $this->generateTableName("idx-$table-$column"),
466 3
                'fk' => $this->generateTableName("fk-$table-$column"),
467 3
                'relatedTable' => $this->generateTableName($relatedTable),
468 3
                'relatedColumn' => $relatedColumn,
469
            ];
470
        }
471
472 9
        return $this->renderFile(Yii::getAlias($templateFile), array_merge($params, [
473 9
            'table' => $this->generateTableName($table),
474 9
            'fields' => $fields,
475 9
            'foreignKeys' => $foreignKeys,
476 9
            'tableComment' => $this->comment,
477
        ]));
478
    }
479
480
    /**
481
     * If `useTablePrefix` equals true, then the table name will contain the
482
     * prefix format.
483
     *
484
     * @param string $tableName the table name to generate.
485
     * @return string
486
     * @since 2.0.8
487
     */
488 9
    protected function generateTableName($tableName)
489
    {
490 9
        if (!$this->useTablePrefix) {
491
            return $tableName;
492
        }
493
494 9
        return '{{%' . $tableName . '}}';
495
    }
496
497
    /**
498
     * Parse the command line migration fields.
499
     * @return array parse result with following fields:
500
     *
501
     * - fields: array, parsed fields
502
     * - foreignKeys: array, detected foreign keys
503
     *
504
     * @since 2.0.7
505
     */
506 9
    protected function parseFields()
507
    {
508 9
        $fields = [];
509 9
        $foreignKeys = [];
510
511 9
        foreach ($this->fields as $index => $field) {
512 4
            $chunks = $this->splitFieldIntoChunks($field);
513 4
            $property = array_shift($chunks);
514
515 4
            foreach ($chunks as $i => &$chunk) {
516 4
                if (strncmp($chunk, 'foreignKey', 10) === 0) {
517 2
                    preg_match('/foreignKey\((\w*)\s?(\w*)\)/', $chunk, $matches);
518 2
                    $foreignKeys[$property] = [
519 2
                        'table' => isset($matches[1])
520 2
                            ? $matches[1]
521 2
                            : preg_replace('/_id$/', '', $property),
522 2
                        'column' => !empty($matches[2])
523
                            ? $matches[2]
524
                            : null,
525
                    ];
526
527 2
                    unset($chunks[$i]);
528 2
                    continue;
529
                }
530
531 4
                if (!preg_match('/^(.+?)\(([^(]+)\)$/', $chunk)) {
532 4
                    $chunk .= '()';
533
                }
534
            }
535 4
            $fields[] = [
536 4
                'property' => $property,
537 4
                'decorators' => implode('->', $chunks),
538
            ];
539
        }
540
541
        return [
542 9
            'fields' => $fields,
543 9
            'foreignKeys' => $foreignKeys,
544
        ];
545
    }
546
547
    /**
548
     * Splits field into chunks
549
     *
550
     * @param string $field
551
     * @return string[]|false
552
     */
553 4
    protected function splitFieldIntoChunks($field)
554
    {
555 4
        $hasDoubleQuotes = false;
556 4
        preg_match_all('/defaultValue\(.*?:.*?\)/', $field, $matches);
557 4
        if (isset($matches[0][0])) {
558 1
            $hasDoubleQuotes = true;
559 1
            $originalDefaultValue = $matches[0][0];
560 1
            $defaultValue = str_replace(':', '{{colon}}', $originalDefaultValue);
561 1
            $field = str_replace($originalDefaultValue, $defaultValue, $field);
562
        }
563
564 4
        $chunks = preg_split('/\s?:\s?/', $field);
565
566 4
        if (is_array($chunks) && $hasDoubleQuotes) {
567 1
            foreach ($chunks as $key => $chunk) {
568 1
                $chunks[$key] = str_replace($defaultValue, $originalDefaultValue, $chunk);
569
            }
570
        }
571
572 4
        return $chunks;
573
    }
574
575
    /**
576
     * Adds default primary key to fields list if there's no primary key specified.
577
     * @param array $fields parsed fields
578
     * @since 2.0.7
579
     */
580 2
    protected function addDefaultPrimaryKey(&$fields)
581
    {
582 2
        foreach ($fields as $field) {
583 2
            if (false !== strripos($field['decorators'], 'primarykey()')) {
584 2
                return;
585
            }
586
        }
587 2
        array_unshift($fields, ['property' => 'id', 'decorators' => 'primaryKey()']);
588 2
    }
589
}
590