Passed
Push — scrutinizer-migrate-to-new-eng... ( 58afd6 )
by Alexander
18:11
created

BaseMigrateController::migrateToVersion()   A

Complexity

Conditions 6
Paths 9

Size

Total Lines 29
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 13
CRAP Score 6.0852

Importance

Changes 0
Metric Value
cc 6
eloc 15
nc 9
nop 1
dl 0
loc 29
ccs 13
cts 15
cp 0.8667
crap 6.0852
rs 9.2222
c 0
b 0
f 0
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\base\BaseObject;
12
use yii\base\InvalidConfigException;
13
use yii\base\NotSupportedException;
14
use yii\console\Controller;
15
use yii\console\Exception;
16
use yii\console\ExitCode;
17
use yii\db\MigrationInterface;
18
use yii\helpers\Console;
19
use yii\helpers\FileHelper;
20
21
/**
22
 * BaseMigrateController is the base class for migrate controllers.
23
 *
24
 * @author Qiang Xue <[email protected]>
25
 * @since 2.0
26
 */
27
abstract class BaseMigrateController extends Controller
28
{
29
    /**
30
     * The name of the dummy migration that marks the beginning of the whole migration history.
31
     */
32
    const BASE_MIGRATION = 'm000000_000000_base';
33
34
    /**
35
     * @var string the default command action.
36
     */
37
    public $defaultAction = 'up';
38
    /**
39
     * @var string|array the directory containing the migration classes. This can be either
40
     * a [path alias](guide:concept-aliases) or a directory path.
41
     *
42
     * Migration classes located at this path should be declared without a namespace.
43
     * Use [[migrationNamespaces]] property in case you are using namespaced migrations.
44
     *
45
     * If you have set up [[migrationNamespaces]], you may set this field to `null` in order
46
     * to disable usage of migrations that are not namespaced.
47
     *
48
     * Since version 2.0.12 you may also specify an array of migration paths that should be searched for
49
     * migrations to load. This is mainly useful to support old extensions that provide migrations
50
     * without namespace and to adopt the new feature of namespaced migrations while keeping existing migrations.
51
     *
52
     * In general, to load migrations from different locations, [[migrationNamespaces]] is the preferable solution
53
     * as the migration name contains the origin of the migration in the history, which is not the case when
54
     * using multiple migration paths.
55
     *
56
     * @see $migrationNamespaces
57
     */
58
    public $migrationPath = ['@app/migrations'];
59
    /**
60
     * @var array list of namespaces containing the migration classes.
61
     *
62
     * Migration namespaces should be resolvable as a [path alias](guide:concept-aliases) if prefixed with `@`, e.g. if you specify
63
     * the namespace `app\migrations`, the code `Yii::getAlias('@app/migrations')` should be able to return
64
     * the file path to the directory this namespace refers to.
65
     * This corresponds with the [autoloading conventions](guide:concept-autoloading) of Yii.
66
     *
67
     * For example:
68
     *
69
     * ```php
70
     * [
71
     *     'app\migrations',
72
     *     'some\extension\migrations',
73
     * ]
74
     * ```
75
     *
76
     * @since 2.0.10
77
     * @see $migrationPath
78
     */
79
    public $migrationNamespaces = [];
80
    /**
81
     * @var string the template file for generating new migrations.
82
     * This can be either a [path alias](guide:concept-aliases) (e.g. "@app/migrations/template.php")
83
     * or a file path.
84
     */
85
    public $templateFile;
86
    /**
87
     * @var bool indicates whether the console output should be compacted.
88
     * If this is set to true, the individual commands ran within the migration will not be output to the console.
89
     * Default is false, in other words the output is fully verbose by default.
90
     * @since 2.0.13
91
     */
92
    public $compact = false;
93
94
95
    /**
96
     * {@inheritdoc}
97
     */
98 46
    public function options($actionID)
99
    {
100 46
        return array_merge(
101 46
            parent::options($actionID),
102 46
            ['migrationPath', 'migrationNamespaces', 'compact'], // global for all actions
103 46
            $actionID === 'create' ? ['templateFile'] : [] // action create
104
        );
105
    }
106
107
    /**
108
     * This method is invoked right before an action is to be executed (after all possible filters.)
109
     * It checks the existence of the [[migrationPath]].
110
     * @param \yii\base\Action $action the action to be executed.
111
     * @throws InvalidConfigException if directory specified in migrationPath doesn't exist and action isn't "create".
112
     * @return bool whether the action should continue to be executed.
113
     */
114 56
    public function beforeAction($action)
115
    {
116 56
        if (parent::beforeAction($action)) {
117 56
            if (empty($this->migrationNamespaces) && empty($this->migrationPath)) {
118
                throw new InvalidConfigException('At least one of `migrationPath` or `migrationNamespaces` should be specified.');
119
            }
120
121 56
            foreach ($this->migrationNamespaces as $key => $value) {
122 8
                $this->migrationNamespaces[$key] = trim($value, '\\');
123
            }
124
125 56
            if (is_array($this->migrationPath)) {
126 7
                foreach ($this->migrationPath as $i => $path) {
127 7
                    $this->migrationPath[$i] = Yii::getAlias($path);
128
                }
129 49
            } elseif ($this->migrationPath !== null) {
130 43
                $path = Yii::getAlias($this->migrationPath);
131 43
                if (!is_dir($path)) {
132 5
                    if ($action->id !== 'create') {
133
                        throw new InvalidConfigException("Migration failed. Directory specified in migrationPath doesn't exist: {$this->migrationPath}");
134
                    }
135 5
                    FileHelper::createDirectory($path);
136
                }
137 43
                $this->migrationPath = $path;
0 ignored issues
show
Documentation Bug introduced by
It seems like $path can also be of type boolean. However, the property $migrationPath is declared as type array|string. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
138
            }
139
140 56
            $version = Yii::getVersion();
141 56
            $this->stdout("Yii Migration Tool (based on Yii v{$version})\n\n");
142
143 56
            return true;
144
        }
145
146
        return false;
147
    }
148
149
    /**
150
     * Upgrades the application by applying new migrations.
151
     *
152
     * For example,
153
     *
154
     * ```
155
     * yii migrate     # apply all new migrations
156
     * yii migrate 3   # apply the first 3 new migrations
157
     * ```
158
     *
159
     * @param int $limit the number of new migrations to be applied. If 0, it means
160
     * applying all available new migrations.
161
     *
162
     * @return int the status of the action execution. 0 means normal, other values mean abnormal.
163
     */
164 44
    public function actionUp($limit = 0)
165
    {
166 44
        $migrations = $this->getNewMigrations();
167 44
        if (empty($migrations)) {
168 1
            $this->stdout("No new migrations found. Your system is up-to-date.\n", Console::FG_GREEN);
169
170 1
            return ExitCode::OK;
171
        }
172
173 43
        $total = count($migrations);
174 43
        $limit = (int) $limit;
175 43
        if ($limit > 0) {
176 4
            $migrations = array_slice($migrations, 0, $limit);
177
        }
178
179 43
        $n = count($migrations);
180 43
        if ($n === $total) {
181 42
            $this->stdout("Total $n new " . ($n === 1 ? 'migration' : 'migrations') . " to be applied:\n", Console::FG_YELLOW);
182
        } else {
183 2
            $this->stdout("Total $n out of $total new " . ($total === 1 ? 'migration' : 'migrations') . " to be applied:\n", Console::FG_YELLOW);
184
        }
185
186 43
        foreach ($migrations as $migration) {
187 43
            $nameLimit = $this->getMigrationNameLimit();
0 ignored issues
show
Bug introduced by
Are you sure the assignment to $nameLimit is correct as $this->getMigrationNameLimit() targeting yii\console\controllers\...getMigrationNameLimit() seems to always return null.

This check looks for function or method calls that always return null and whose return value is assigned to a variable.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
$object = $a->getObject();

The method getObject() can return nothing but null, so it makes no sense to assign that value to a variable.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
188 43
            if ($nameLimit !== null && strlen($migration) > $nameLimit) {
189 1
                $this->stdout("\nThe migration name '$migration' is too long. Its not possible to apply this migration.\n", Console::FG_RED);
190 1
                return ExitCode::UNSPECIFIED_ERROR;
191
            }
192 42
            $this->stdout("\t$migration\n");
193
        }
194 42
        $this->stdout("\n");
195
196 42
        $applied = 0;
197 42
        if ($this->confirm('Apply the above ' . ($n === 1 ? 'migration' : 'migrations') . '?')) {
198 42
            foreach ($migrations as $migration) {
199 42
                if (!$this->migrateUp($migration)) {
200
                    $this->stdout("\n$applied from $n " . ($applied === 1 ? 'migration was' : 'migrations were') . " applied.\n", Console::FG_RED);
201
                    $this->stdout("\nMigration failed. The rest of the migrations are canceled.\n", Console::FG_RED);
202
203
                    return ExitCode::UNSPECIFIED_ERROR;
204
                }
205 42
                $applied++;
206
            }
207
208 42
            $this->stdout("\n$n " . ($n === 1 ? 'migration was' : 'migrations were') . " applied.\n", Console::FG_GREEN);
209 42
            $this->stdout("\nMigrated up successfully.\n", Console::FG_GREEN);
210
        }
211 42
    }
212
213
    /**
214
     * Downgrades the application by reverting old migrations.
215
     *
216
     * For example,
217
     *
218
     * ```
219
     * yii migrate/down     # revert the last migration
220
     * yii migrate/down 3   # revert the last 3 migrations
221
     * yii migrate/down all # revert all migrations
222
     * ```
223
     *
224
     * @param int|string $limit the number of migrations to be reverted. Defaults to 1,
225
     * meaning the last applied migration will be reverted. When value is "all", all migrations will be reverted.
226
     * @throws Exception if the number of the steps specified is less than 1.
227
     *
228
     * @return int the status of the action execution. 0 means normal, other values mean abnormal.
229
     */
230 31
    public function actionDown($limit = 1)
231
    {
232 31
        if ($limit === 'all') {
233 22
            $limit = null;
234
        } else {
235 12
            $limit = (int) $limit;
236 12
            if ($limit < 1) {
237
                throw new Exception('The step argument must be greater than 0.');
238
            }
239
        }
240
241 31
        $migrations = $this->getMigrationHistory($limit);
242
243 31
        if (empty($migrations)) {
244 21
            $this->stdout("No migration has been done before.\n", Console::FG_YELLOW);
245
246 21
            return ExitCode::OK;
247
        }
248
249 31
        $migrations = array_keys($migrations);
250
251 31
        $n = count($migrations);
252 31
        $this->stdout("Total $n " . ($n === 1 ? 'migration' : 'migrations') . " to be reverted:\n", Console::FG_YELLOW);
253 31
        foreach ($migrations as $migration) {
254 31
            $this->stdout("\t$migration\n");
255
        }
256 31
        $this->stdout("\n");
257
258 31
        $reverted = 0;
259 31
        if ($this->confirm('Revert the above ' . ($n === 1 ? 'migration' : 'migrations') . '?')) {
260 31
            foreach ($migrations as $migration) {
261 31
                if (!$this->migrateDown($migration)) {
262
                    $this->stdout("\n$reverted from $n " . ($reverted === 1 ? 'migration was' : 'migrations were') . " reverted.\n", Console::FG_RED);
263
                    $this->stdout("\nMigration failed. The rest of the migrations are canceled.\n", Console::FG_RED);
264
265
                    return ExitCode::UNSPECIFIED_ERROR;
266
                }
267 31
                $reverted++;
268
            }
269 31
            $this->stdout("\n$n " . ($n === 1 ? 'migration was' : 'migrations were') . " reverted.\n", Console::FG_GREEN);
270 31
            $this->stdout("\nMigrated down successfully.\n", Console::FG_GREEN);
271
        }
272 31
    }
273
274
    /**
275
     * Redoes the last few migrations.
276
     *
277
     * This command will first revert the specified migrations, and then apply
278
     * them again. For example,
279
     *
280
     * ```
281
     * yii migrate/redo     # redo the last applied migration
282
     * yii migrate/redo 3   # redo the last 3 applied migrations
283
     * yii migrate/redo all # redo all migrations
284
     * ```
285
     *
286
     * @param int|string $limit the number of migrations to be redone. Defaults to 1,
287
     * meaning the last applied migration will be redone. When equals "all", all migrations will be redone.
288
     * @throws Exception if the number of the steps specified is less than 1.
289
     *
290
     * @return int the status of the action execution. 0 means normal, other values mean abnormal.
291
     */
292 2
    public function actionRedo($limit = 1)
293
    {
294 2
        if ($limit === 'all') {
295
            $limit = null;
296
        } else {
297 2
            $limit = (int) $limit;
298 2
            if ($limit < 1) {
299
                throw new Exception('The step argument must be greater than 0.');
300
            }
301
        }
302
303 2
        $migrations = $this->getMigrationHistory($limit);
304
305 2
        if (empty($migrations)) {
306
            $this->stdout("No migration has been done before.\n", Console::FG_YELLOW);
307
308
            return ExitCode::OK;
309
        }
310
311 2
        $migrations = array_keys($migrations);
312
313 2
        $n = count($migrations);
314 2
        $this->stdout("Total $n " . ($n === 1 ? 'migration' : 'migrations') . " to be redone:\n", Console::FG_YELLOW);
315 2
        foreach ($migrations as $migration) {
316 2
            $this->stdout("\t$migration\n");
317
        }
318 2
        $this->stdout("\n");
319
320 2
        if ($this->confirm('Redo the above ' . ($n === 1 ? 'migration' : 'migrations') . '?')) {
321 2
            foreach ($migrations as $migration) {
322 2
                if (!$this->migrateDown($migration)) {
323
                    $this->stdout("\nMigration failed. The rest of the migrations are canceled.\n", Console::FG_RED);
324
325 2
                    return ExitCode::UNSPECIFIED_ERROR;
326
                }
327
            }
328 2
            foreach (array_reverse($migrations) as $migration) {
329 2
                if (!$this->migrateUp($migration)) {
330
                    $this->stdout("\nMigration failed. The rest of the migrations are canceled.\n", Console::FG_RED);
331
332 2
                    return ExitCode::UNSPECIFIED_ERROR;
333
                }
334
            }
335 2
            $this->stdout("\n$n " . ($n === 1 ? 'migration was' : 'migrations were') . " redone.\n", Console::FG_GREEN);
336 2
            $this->stdout("\nMigration redone successfully.\n", Console::FG_GREEN);
337
        }
338 2
    }
339
340
    /**
341
     * Upgrades or downgrades till the specified version.
342
     *
343
     * Can also downgrade versions to the certain apply time in the past by providing
344
     * a UNIX timestamp or a string parseable by the strtotime() function. This means
345
     * that all the versions applied after the specified certain time would be reverted.
346
     *
347
     * This command will first revert the specified migrations, and then apply
348
     * them again. For example,
349
     *
350
     * ```
351
     * yii migrate/to 101129_185401                          # using timestamp
352
     * yii migrate/to m101129_185401_create_user_table       # using full name
353
     * yii migrate/to 1392853618                             # using UNIX timestamp
354
     * yii migrate/to "2014-02-15 13:00:50"                  # using strtotime() parseable string
355
     * yii migrate/to app\migrations\M101129185401CreateUser # using full namespace name
356
     * ```
357
     *
358
     * @param string $version either the version name or the certain time value in the past
359
     * that the application should be migrated to. This can be either the timestamp,
360
     * the full name of the migration, the UNIX timestamp, or the parseable datetime
361
     * string.
362
     * @throws Exception if the version argument is invalid.
363
     */
364 3
    public function actionTo($version)
365
    {
366 3
        if (($namespaceVersion = $this->extractNamespaceMigrationVersion($version)) !== false) {
367 1
            $this->migrateToVersion($namespaceVersion);
368 2
        } elseif (($migrationName = $this->extractMigrationVersion($version)) !== false) {
369 2
            $this->migrateToVersion($migrationName);
370
        } elseif ((string) (int) $version == $version) {
371
            $this->migrateToTime($version);
0 ignored issues
show
Bug introduced by
$version of type string is incompatible with the type integer expected by parameter $time of yii\console\controllers\...roller::migrateToTime(). ( Ignorable by Annotation )

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

371
            $this->migrateToTime(/** @scrutinizer ignore-type */ $version);
Loading history...
372
        } elseif (($time = strtotime($version)) !== false) {
373
            $this->migrateToTime($time);
374
        } else {
375
            throw new Exception("The version argument must be either a timestamp (e.g. 101129_185401),\n the full name of a migration (e.g. m101129_185401_create_user_table),\n the full namespaced name of a migration (e.g. app\\migrations\\M101129185401CreateUserTable),\n a UNIX timestamp (e.g. 1392853000), or a datetime string parseable\nby the strtotime() function (e.g. 2014-02-15 13:00:50).");
376
        }
377 3
    }
378
379
    /**
380
     * Modifies the migration history to the specified version.
381
     *
382
     * No actual migration will be performed.
383
     *
384
     * ```
385
     * yii migrate/mark 101129_185401                        # using timestamp
386
     * yii migrate/mark m101129_185401_create_user_table     # using full name
387
     * yii migrate/mark app\migrations\M101129185401CreateUser # using full namespace name
388
     * yii migrate/mark m000000_000000_base # reset the complete migration history
389
     * ```
390
     *
391
     * @param string $version the version at which the migration history should be marked.
392
     * This can be either the timestamp or the full name of the migration.
393
     * You may specify the name `m000000_000000_base` to set the migration history to a
394
     * state where no migration has been applied.
395
     * @return int CLI exit code
396
     * @throws Exception if the version argument is invalid or the version cannot be found.
397
     */
398 4
    public function actionMark($version)
399
    {
400 4
        $originalVersion = $version;
401 4
        if (($namespaceVersion = $this->extractNamespaceMigrationVersion($version)) !== false) {
402 1
            $version = $namespaceVersion;
403 3
        } elseif (($migrationName = $this->extractMigrationVersion($version)) !== false) {
404 3
            $version = $migrationName;
405
        } elseif ($version !== static::BASE_MIGRATION) {
406
            throw new Exception("The version argument must be either a timestamp (e.g. 101129_185401)\nor the full name of a migration (e.g. m101129_185401_create_user_table)\nor the full name of a namespaced migration (e.g. app\\migrations\\M101129185401CreateUserTable).");
407
        }
408
409
        // try mark up
410 4
        $migrations = $this->getNewMigrations();
411 4
        foreach ($migrations as $i => $migration) {
412 3
            if (strpos($migration, $version) === 0) {
413 3
                if ($this->confirm("Set migration history at $originalVersion?")) {
414 3
                    for ($j = 0; $j <= $i; ++$j) {
415 3
                        $this->addMigrationHistory($migrations[$j]);
416
                    }
417 3
                    $this->stdout("The migration history is set at $originalVersion.\nNo actual migration was performed.\n", Console::FG_GREEN);
418
                }
419
420 3
                return ExitCode::OK;
421
            }
422
        }
423
424
        // try mark down
425 1
        $migrations = array_keys($this->getMigrationHistory(null));
426 1
        $migrations[] = static::BASE_MIGRATION;
427 1
        foreach ($migrations as $i => $migration) {
428 1
            if (strpos($migration, $version) === 0) {
429 1
                if ($i === 0) {
430
                    $this->stdout("Already at '$originalVersion'. Nothing needs to be done.\n", Console::FG_YELLOW);
431
                } else {
432 1
                    if ($this->confirm("Set migration history at $originalVersion?")) {
433 1
                        for ($j = 0; $j < $i; ++$j) {
434 1
                            $this->removeMigrationHistory($migrations[$j]);
435
                        }
436 1
                        $this->stdout("The migration history is set at $originalVersion.\nNo actual migration was performed.\n", Console::FG_GREEN);
437
                    }
438
                }
439
440 1
                return ExitCode::OK;
441
            }
442
        }
443
444
        throw new Exception("Unable to find the version '$originalVersion'.");
445
    }
446
447
    /**
448
     * Truncates the whole database and starts the migration from the beginning.
449
     *
450
     * ```
451
     * yii migrate/fresh
452
     * ```
453
     *
454
     * @since 2.0.13
455
     */
456 1
    public function actionFresh()
457
    {
458 1
        if (YII_ENV_PROD) {
459
            $this->stdout("YII_ENV is set to 'prod'.\nRefreshing migrations is not possible on production systems.\n");
460
            return ExitCode::OK;
461
        }
462
463 1
        if ($this->confirm(
464 1
            "Are you sure you want to reset the database and start the migration from the beginning?\nAll data will be lost irreversibly!")) {
465 1
            $this->truncateDatabase();
466 1
            $this->actionUp();
467
        } else {
468
            $this->stdout('Action was cancelled by user. Nothing has been performed.');
469
        }
470 1
    }
471
472
    /**
473
     * Checks if given migration version specification matches namespaced migration name.
474
     * @param string $rawVersion raw version specification received from user input.
475
     * @return string|false actual migration version, `false` - if not match.
476
     * @since 2.0.10
477
     */
478 6
    private function extractNamespaceMigrationVersion($rawVersion)
479
    {
480 6
        if (preg_match('/^\\\\?([\w_]+\\\\)+m(\d{6}_?\d{6})(\D.*)?$/is', $rawVersion, $matches)) {
481 2
            return trim($rawVersion, '\\');
482
        }
483
484 4
        return false;
485
    }
486
487
    /**
488
     * Checks if given migration version specification matches migration base name.
489
     * @param string $rawVersion raw version specification received from user input.
490
     * @return string|false actual migration version, `false` - if not match.
491
     * @since 2.0.10
492
     */
493 4
    private function extractMigrationVersion($rawVersion)
494
    {
495 4
        if (preg_match('/^m?(\d{6}_?\d{6})(\D.*)?$/is', $rawVersion, $matches)) {
496 4
            return 'm' . $matches[1];
497
        }
498
499
        return false;
500
    }
501
502
    /**
503
     * Displays the migration history.
504
     *
505
     * This command will show the list of migrations that have been applied
506
     * so far. For example,
507
     *
508
     * ```
509
     * yii migrate/history     # showing the last 10 migrations
510
     * yii migrate/history 5   # showing the last 5 migrations
511
     * yii migrate/history all # showing the whole history
512
     * ```
513
     *
514
     * @param int|string $limit the maximum number of migrations to be displayed.
515
     * If it is "all", the whole migration history will be displayed.
516
     * @throws \yii\console\Exception if invalid limit value passed
517
     */
518 6
    public function actionHistory($limit = 10)
519
    {
520 6
        if ($limit === 'all') {
521
            $limit = null;
522
        } else {
523 6
            $limit = (int) $limit;
524 6
            if ($limit < 1) {
525
                throw new Exception('The limit must be greater than 0.');
526
            }
527
        }
528
529 6
        $migrations = $this->getMigrationHistory($limit);
530
531 6
        if (empty($migrations)) {
532 6
            $this->stdout("No migration has been done before.\n", Console::FG_YELLOW);
533
        } else {
534 2
            $n = count($migrations);
535 2
            if ($limit > 0) {
536 2
                $this->stdout("Showing the last $n applied " . ($n === 1 ? 'migration' : 'migrations') . ":\n", Console::FG_YELLOW);
537
            } else {
538
                $this->stdout("Total $n " . ($n === 1 ? 'migration has' : 'migrations have') . " been applied before:\n", Console::FG_YELLOW);
539
            }
540 2
            foreach ($migrations as $version => $time) {
541 2
                $this->stdout("\t(" . date('Y-m-d H:i:s', $time) . ') ' . $version . "\n");
542
            }
543
        }
544 6
    }
545
546
    /**
547
     * Displays the un-applied new migrations.
548
     *
549
     * This command will show the new migrations that have not been applied.
550
     * For example,
551
     *
552
     * ```
553
     * yii migrate/new     # showing the first 10 new migrations
554
     * yii migrate/new 5   # showing the first 5 new migrations
555
     * yii migrate/new all # showing all new migrations
556
     * ```
557
     *
558
     * @param int|string $limit the maximum number of new migrations to be displayed.
559
     * If it is `all`, all available new migrations will be displayed.
560
     * @throws \yii\console\Exception if invalid limit value passed
561
     */
562 1
    public function actionNew($limit = 10)
563
    {
564 1
        if ($limit === 'all') {
565
            $limit = null;
566
        } else {
567 1
            $limit = (int) $limit;
568 1
            if ($limit < 1) {
569
                throw new Exception('The limit must be greater than 0.');
570
            }
571
        }
572
573 1
        $migrations = $this->getNewMigrations();
574
575 1
        if (empty($migrations)) {
576 1
            $this->stdout("No new migrations found. Your system is up-to-date.\n", Console::FG_GREEN);
577
        } else {
578 1
            $n = count($migrations);
579 1
            if ($limit && $n > $limit) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $limit of type integer|null is loosely compared to true; this is ambiguous if the integer can be 0. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For integer values, zero is a special case, in particular the following results might be unexpected:

0   == false // true
0   == null  // true
123 == false // false
123 == null  // false

// It is often better to use strict comparison
0 === false // false
0 === null  // false
Loading history...
580
                $migrations = array_slice($migrations, 0, $limit);
581
                $this->stdout("Showing $limit out of $n new " . ($n === 1 ? 'migration' : 'migrations') . ":\n", Console::FG_YELLOW);
582
            } else {
583 1
                $this->stdout("Found $n new " . ($n === 1 ? 'migration' : 'migrations') . ":\n", Console::FG_YELLOW);
584
            }
585
586 1
            foreach ($migrations as $migration) {
587 1
                $this->stdout("\t" . $migration . "\n");
588
            }
589
        }
590 1
    }
591
592
    /**
593
     * Creates a new migration.
594
     *
595
     * This command creates a new migration using the available migration template.
596
     * After using this command, developers should modify the created migration
597
     * skeleton by filling up the actual migration logic.
598
     *
599
     * ```
600
     * yii migrate/create create_user_table
601
     * ```
602
     *
603
     * In order to generate a namespaced migration, you should specify a namespace before the migration's name.
604
     * Note that backslash (`\`) is usually considered a special character in the shell, so you need to escape it
605
     * properly to avoid shell errors or incorrect behavior.
606
     * For example:
607
     *
608
     * ```
609
     * yii migrate/create 'app\\migrations\\createUserTable'
610
     * ```
611
     *
612
     * In case [[migrationPath]] is not set and no namespace is provided, the first entry of [[migrationNamespaces]] will be used.
613
     *
614
     * @param string $name the name of the new migration. This should only contain
615
     * letters, digits, underscores and/or backslashes.
616
     *
617
     * Note: If the migration name is of a special form, for example create_xxx or
618
     * drop_xxx, then the generated migration file will contain extra code,
619
     * in this case for creating/dropping tables.
620
     *
621
     * @throws Exception if the name argument is invalid.
622
     */
623 10
    public function actionCreate($name)
624
    {
625 10
        if (!preg_match('/^[\w\\\\]+$/', $name)) {
626
            throw new Exception('The migration name should contain letters, digits, underscore and/or backslash characters only.');
627
        }
628
629 10
        list($namespace, $className) = $this->generateClassName($name);
630
        // Abort if name is too long
631 10
        $nameLimit = $this->getMigrationNameLimit();
0 ignored issues
show
Bug introduced by
Are you sure the assignment to $nameLimit is correct as $this->getMigrationNameLimit() targeting yii\console\controllers\...getMigrationNameLimit() seems to always return null.

This check looks for function or method calls that always return null and whose return value is assigned to a variable.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
$object = $a->getObject();

The method getObject() can return nothing but null, so it makes no sense to assign that value to a variable.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
632 10
        if ($nameLimit !== null && strlen($className) > $nameLimit) {
0 ignored issues
show
introduced by
The condition $nameLimit !== null is always false.
Loading history...
633 1
            throw new Exception('The migration name is too long.');
634
        }
635
636 9
        $migrationPath = $this->findMigrationPath($namespace);
637
638 9
        $file = $migrationPath . DIRECTORY_SEPARATOR . $className . '.php';
639 9
        if ($this->confirm("Create new migration '$file'?")) {
640 9
            $content = $this->generateMigrationSourceCode([
641 9
                'name' => $name,
642 9
                'className' => $className,
643 9
                'namespace' => $namespace,
644
            ]);
645 9
            FileHelper::createDirectory($migrationPath);
646 9
            file_put_contents($file, $content, LOCK_EX);
647 9
            $this->stdout("New migration created successfully.\n", Console::FG_GREEN);
648
        }
649 9
    }
650
651
    /**
652
     * Generates class base name and namespace from migration name from user input.
653
     * @param string $name migration name from user input.
654
     * @return array list of 2 elements: 'namespace' and 'class base name'
655
     * @since 2.0.10
656
     */
657 10
    private function generateClassName($name)
658
    {
659 10
        $namespace = null;
660 10
        $name = trim($name, '\\');
661 10
        if (strpos($name, '\\') !== false) {
662 1
            $namespace = substr($name, 0, strrpos($name, '\\'));
663 1
            $name = substr($name, strrpos($name, '\\') + 1);
664
        } else {
665 10
            if ($this->migrationPath === null) {
666 1
                $migrationNamespaces = $this->migrationNamespaces;
667 1
                $namespace = array_shift($migrationNamespaces);
668
            }
669
        }
670
671 10
        if ($namespace === null) {
672 10
            $class = 'm' . gmdate('ymd_His') . '_' . $name;
673
        } else {
674 1
            $class = 'M' . gmdate('ymdHis') . ucfirst($name);
675
        }
676
677 10
        return [$namespace, $class];
678
    }
679
680
    /**
681
     * Finds the file path for the specified migration namespace.
682
     * @param string|null $namespace migration namespace.
683
     * @return string migration file path.
684
     * @throws Exception on failure.
685
     * @since 2.0.10
686
     */
687 9
    private function findMigrationPath($namespace)
688
    {
689 9
        if (empty($namespace)) {
690 9
            return is_array($this->migrationPath) ? reset($this->migrationPath) : $this->migrationPath;
691
        }
692
693 1
        if (!in_array($namespace, $this->migrationNamespaces, true)) {
694
            throw new Exception("Namespace '{$namespace}' not found in `migrationNamespaces`");
695
        }
696
697 1
        return $this->getNamespacePath($namespace);
698
    }
699
700
    /**
701
     * Returns the file path matching the give namespace.
702
     * @param string $namespace namespace.
703
     * @return string file path.
704
     * @since 2.0.10
705
     */
706 7
    private function getNamespacePath($namespace)
707
    {
708 7
        return str_replace('/', DIRECTORY_SEPARATOR, Yii::getAlias('@' . str_replace('\\', '/', $namespace)));
709
    }
710
711
    /**
712
     * Upgrades with the specified migration class.
713
     * @param string $class the migration class name
714
     * @return bool whether the migration is successful
715
     */
716 42
    protected function migrateUp($class)
717
    {
718 42
        if ($class === self::BASE_MIGRATION) {
719
            return true;
720
        }
721
722 42
        $this->stdout("*** applying $class\n", Console::FG_YELLOW);
723 42
        $start = microtime(true);
724 42
        $migration = $this->createMigration($class);
725 42
        if ($migration->up() !== false) {
726 42
            $this->addMigrationHistory($class);
727 42
            $time = microtime(true) - $start;
728 42
            $this->stdout("*** applied $class (time: " . sprintf('%.3f', $time) . "s)\n\n", Console::FG_GREEN);
729
730 42
            return true;
731
        }
732
733
        $time = microtime(true) - $start;
734
        $this->stdout("*** failed to apply $class (time: " . sprintf('%.3f', $time) . "s)\n\n", Console::FG_RED);
735
736
        return false;
737
    }
738
739
    /**
740
     * Downgrades with the specified migration class.
741
     * @param string $class the migration class name
742
     * @return bool whether the migration is successful
743
     */
744 32
    protected function migrateDown($class)
745
    {
746 32
        if ($class === self::BASE_MIGRATION) {
747
            return true;
748
        }
749
750 32
        $this->stdout("*** reverting $class\n", Console::FG_YELLOW);
751 32
        $start = microtime(true);
752 32
        $migration = $this->createMigration($class);
753 32
        if ($migration->down() !== false) {
754 32
            $this->removeMigrationHistory($class);
755 32
            $time = microtime(true) - $start;
756 32
            $this->stdout("*** reverted $class (time: " . sprintf('%.3f', $time) . "s)\n\n", Console::FG_GREEN);
757
758 32
            return true;
759
        }
760
761
        $time = microtime(true) - $start;
762
        $this->stdout("*** failed to revert $class (time: " . sprintf('%.3f', $time) . "s)\n\n", Console::FG_RED);
763
764
        return false;
765
    }
766
767
    /**
768
     * Creates a new migration instance.
769
     * @param string $class the migration class name
770
     * @return \yii\db\MigrationInterface the migration instance
771
     */
772
    protected function createMigration($class)
773
    {
774
        $this->includeMigrationFile($class);
775
776
        /** @var MigrationInterface $migration */
777
        $migration = Yii::createObject($class);
778
        if ($migration instanceof BaseObject && $migration->canSetProperty('compact')) {
779
            $migration->compact = $this->compact;
0 ignored issues
show
Bug Best Practice introduced by
The property compact does not exist on yii\base\BaseObject. Since you implemented __set, consider adding a @property annotation.
Loading history...
780
        }
781
782
        return $migration;
783
    }
784
785
    /**
786
     * Includes the migration file for a given migration class name.
787
     *
788
     * This function will do nothing on namespaced migrations, which are loaded by
789
     * autoloading automatically. It will include the migration file, by searching
790
     * [[migrationPath]] for classes without namespace.
791
     * @param string $class the migration class name.
792
     * @since 2.0.12
793
     */
794 42
    protected function includeMigrationFile($class)
795
    {
796 42
        $class = trim($class, '\\');
797 42
        if (strpos($class, '\\') === false) {
798 38
            if (is_array($this->migrationPath)) {
799 7
                foreach ($this->migrationPath as $path) {
800 7
                    $file = $path . DIRECTORY_SEPARATOR . $class . '.php';
801 7
                    if (is_file($file)) {
802 7
                        require_once $file;
803 7
                        break;
804
                    }
805
                }
806
            } else {
807 31
                $file = $this->migrationPath . DIRECTORY_SEPARATOR . $class . '.php';
808 31
                require_once $file;
809
            }
810
        }
811 42
    }
812
813
    /**
814
     * Migrates to the specified apply time in the past.
815
     * @param int $time UNIX timestamp value.
816
     */
817
    protected function migrateToTime($time)
818
    {
819
        $count = 0;
820
        $migrations = array_values($this->getMigrationHistory(null));
821
        while ($count < count($migrations) && $migrations[$count] > $time) {
822
            ++$count;
823
        }
824
        if ($count === 0) {
825
            $this->stdout("Nothing needs to be done.\n", Console::FG_GREEN);
826
        } else {
827
            $this->actionDown($count);
828
        }
829
    }
830
831
    /**
832
     * Migrates to the certain version.
833
     * @param string $version name in the full format.
834
     * @return int CLI exit code
835
     * @throws Exception if the provided version cannot be found.
836
     */
837 3
    protected function migrateToVersion($version)
838
    {
839 3
        $originalVersion = $version;
840
841
        // try migrate up
842 3
        $migrations = $this->getNewMigrations();
843 3
        foreach ($migrations as $i => $migration) {
844 2
            if (strpos($migration, $version) === 0) {
845 2
                $this->actionUp($i + 1);
846
847 2
                return ExitCode::OK;
848
            }
849
        }
850
851
        // try migrate down
852 1
        $migrations = array_keys($this->getMigrationHistory(null));
853 1
        foreach ($migrations as $i => $migration) {
854 1
            if (strpos($migration, $version) === 0) {
855 1
                if ($i === 0) {
856
                    $this->stdout("Already at '$originalVersion'. Nothing needs to be done.\n", Console::FG_YELLOW);
857
                } else {
858 1
                    $this->actionDown($i);
859
                }
860
861 1
                return ExitCode::OK;
862
            }
863
        }
864
865
        throw new Exception("Unable to find the version '$originalVersion'.");
866
    }
867
868
    /**
869
     * Returns the migrations that are not applied.
870
     * @return array list of new migrations
871
     */
872 46
    protected function getNewMigrations()
873
    {
874 46
        $applied = [];
875 46
        foreach ($this->getMigrationHistory(null) as $class => $time) {
876 3
            $applied[trim($class, '\\')] = true;
877
        }
878
879 46
        $migrationPaths = [];
880 46
        if (is_array($this->migrationPath)) {
881 7
            foreach ($this->migrationPath as $path) {
882 7
                $migrationPaths[] = [$path, ''];
883
            }
884 39
        } elseif (!empty($this->migrationPath)) {
885 34
            $migrationPaths[] = [$this->migrationPath, ''];
886
        }
887 46
        foreach ($this->migrationNamespaces as $namespace) {
888 6
            $migrationPaths[] = [$this->getNamespacePath($namespace), $namespace];
889
        }
890
891 46
        $migrations = [];
892 46
        foreach ($migrationPaths as $item) {
893 46
            list($migrationPath, $namespace) = $item;
894 46
            if (!file_exists($migrationPath)) {
895
                continue;
896
            }
897 46
            $handle = opendir($migrationPath);
898 46
            while (($file = readdir($handle)) !== false) {
0 ignored issues
show
Bug introduced by
It seems like $handle can also be of type false; however, parameter $dir_handle of readdir() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

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

898
            while (($file = readdir(/** @scrutinizer ignore-type */ $handle)) !== false) {
Loading history...
899 46
                if ($file === '.' || $file === '..') {
900 46
                    continue;
901
                }
902 45
                $path = $migrationPath . DIRECTORY_SEPARATOR . $file;
903 45
                if (preg_match('/^(m(\d{6}_?\d{6})\D.*?)\.php$/is', $file, $matches) && is_file($path)) {
904 45
                    $class = $matches[1];
905 45
                    if (!empty($namespace)) {
906 6
                        $class = $namespace . '\\' . $class;
907
                    }
908 45
                    $time = str_replace('_', '', $matches[2]);
909 45
                    if (!isset($applied[$class])) {
910 45
                        $migrations[$time . '\\' . $class] = $class;
911
                    }
912
                }
913
            }
914 46
            closedir($handle);
0 ignored issues
show
Bug introduced by
It seems like $handle can also be of type false; however, parameter $dir_handle of closedir() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

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

914
            closedir(/** @scrutinizer ignore-type */ $handle);
Loading history...
915
        }
916 46
        ksort($migrations);
917
918 46
        return array_values($migrations);
919
    }
920
921
    /**
922
     * Generates new migration source PHP code.
923
     * Child class may override this method, adding extra logic or variation to the process.
924
     * @param array $params generation parameters, usually following parameters are present:
925
     *
926
     *  - name: string migration base name
927
     *  - className: string migration class name
928
     *
929
     * @return string generated PHP code.
930
     * @since 2.0.8
931
     */
932
    protected function generateMigrationSourceCode($params)
933
    {
934
        return $this->renderFile(Yii::getAlias($this->templateFile), $params);
935
    }
936
937
    /**
938
     * Truncates the database.
939
     * This method should be overwritten in subclasses to implement the task of clearing the database.
940
     * @throws NotSupportedException if not overridden
941
     * @since 2.0.13
942
     */
943
    protected function truncateDatabase()
944
    {
945
        throw new NotSupportedException('This command is not implemented in ' . get_class($this));
946
    }
947
948
    /**
949
     * Return the maximum name length for a migration.
950
     *
951
     * Subclasses may override this method to define a limit.
952
     * @return int|null the maximum name length for a migration or `null` if no limit applies.
953
     * @since 2.0.13
954
     */
955
    protected function getMigrationNameLimit()
956
    {
957
        return null;
958
    }
959
960
    /**
961
     * Returns the migration history.
962
     * @param int $limit the maximum number of records in the history to be returned. `null` for "no limit".
963
     * @return array the migration history
964
     */
965
    abstract protected function getMigrationHistory($limit);
966
967
    /**
968
     * Adds new migration entry to the history.
969
     * @param string $version migration version name.
970
     */
971
    abstract protected function addMigrationHistory($version);
972
973
    /**
974
     * Removes existing migration from the history.
975
     * @param string $version migration version name.
976
     */
977
    abstract protected function removeMigrationHistory($version);
978
}
979