Issues (910)

console/controllers/MigrateController.php (1 issue)

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

183
            $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...
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, [
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);
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