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) {
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());
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

448
                    $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...
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