Passed
Push — master ( 07d7eb...5150f4 )
by Gaetano
10:15
created

MigrateCommand   F

Complexity

Total Complexity 60

Size/Duplication

Total Lines 425
Duplicated Lines 0 %

Test Coverage

Coverage 54.07%

Importance

Changes 0
Metric Value
eloc 211
dl 0
loc 425
ccs 106
cts 196
cp 0.5407
rs 3.6
c 0
b 0
f 0
wmc 60

11 Methods

Rating   Name   Duplication   Size   Complexity  
C execute() 0 110 16
B createChildProcessArgs() 0 38 8
A askForConfirmation() 0 20 5
A executeMigrationInProcess() 0 9 1
A printMigrationsList() 0 25 4
B buildMigrationsList() 0 37 11
A writeln() 0 4 2
B executeMigrationInSeparateProcess() 0 59 10
A setOutput() 0 3 1
A setVerbosity() 0 3 1
A configure() 0 21 1

How to fix   Complexity   

Complex Class

Complex classes like MigrateCommand often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use MigrateCommand, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
namespace Kaliop\eZMigrationBundle\Command;
4
5
use Symfony\Component\Console\Input\ArrayInput;
6
use Symfony\Component\Console\Input\InputInterface;
7
use Symfony\Component\Console\Input\InputOption;
8
use Symfony\Component\Console\Output\OutputInterface;
9
use Symfony\Component\Console\Helper\Table;
10
use Symfony\Component\Console\Question\ConfirmationQuestion;
11
use Symfony\Component\Process\ProcessBuilder;
12
use Symfony\Component\Process\PhpExecutableFinder;
13
use Kaliop\eZMigrationBundle\API\Value\MigrationDefinition;
14
use Kaliop\eZMigrationBundle\API\Value\Migration;
15
use Kaliop\eZMigrationBundle\API\Exception\AfterMigrationExecutionException;
16
use Kaliop\eZMigrationBundle\Core\MigrationService;
17
18
/**
19
 * Command to execute the available migration definitions.
20
 */
21
class MigrateCommand extends AbstractCommand
22
{
23
    // in between QUIET and NORMAL
24
    const VERBOSITY_CHILD = 0.5;
25
    /** @var OutputInterface $output */
26
    protected $output;
27
    protected $verbosity = OutputInterface::VERBOSITY_NORMAL;
28
29
    protected $processTimeout = 86400;
30
31
    const COMMAND_NAME = 'kaliop:migration:migrate';
32
33
    /**
34
     * Set up the command.
35
     *
36 78
     * Define the name, options and help text.
37
     */
38 78
    protected function configure()
39
    {
40
        parent::configure();
41 78
42 78
        $this
43 78
            ->setName(self::COMMAND_NAME)
44
            ->setAliases(array('kaliop:migration:update'))
45 78
            ->setDescription('Execute available migration definitions.')
46 78
            // nb: when adding options, remember to forward them to sub-commands executed in 'separate-process' mode
47 78
            ->addOption('admin-login', 'a', InputOption::VALUE_REQUIRED, "Login of admin account used whenever elevated privileges are needed (user id 14 used by default)")
48 78
            ->addOption('clear-cache', 'c', InputOption::VALUE_NONE, "Clear the cache after the command finishes")
49 78
            ->addOption('default-language', 'l', InputOption::VALUE_REQUIRED, "Default language code that will be used if no language is provided in migration steps")
50 78
            ->addOption('force', 'f', InputOption::VALUE_NONE, "Force (re)execution of migrations already DONE, SKIPPED or FAILED. Use with great care!")
51 78
            ->addOption('ignore-failures', 'i', InputOption::VALUE_NONE, "Keep executing migrations even if one fails")
52 78
            ->addOption('no-interaction', 'n', InputOption::VALUE_NONE, "Do not ask any interactive question")
53 78
            ->addOption('no-transactions', 'u', InputOption::VALUE_NONE, "Do not use a repository transaction to wrap each migration. Unsafe, but needed for legacy slot handlers")
54 78
            ->addOption('path', null, InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY, "The directory or file to load the migration definitions from")
55 78
            ->addOption('separate-process', 'p', InputOption::VALUE_NONE, "Use a separate php process to run each migration. Safe if your migration leak memory. A tad slower")
56 78
            ->addOption('force-sigchild-handling', null, InputOption::VALUE_NONE, "When using a separate php process to run each migration, tell Symfony that php was compiled with --enable-sigchild option")
57
            ->addOption('child', null, InputOption::VALUE_NONE, "*DO NOT USE* Internal option for when forking separate processes")
58
            ->setHelp(<<<EOT
59
The <info>kaliop:migration:migrate</info> command loads and executes migrations:
60
61
    <info>./ezpublish/console kaliop:migration:migrate</info>
62
63
You can optionally specify the path to migration definitions with <info>--path</info>:
64
65 78
    <info>./ezpublish/console kaliop:migrations:migrate --path=/path/to/bundle/version_directory --path=/path/to/bundle/version_directory/single_migration_file</info>
66
EOT
67
            );
68
    }
69
70
    /**
71
     * Execute the command.
72
     *
73
     * @param InputInterface $input
74 47
     * @param OutputInterface $output
75
     * @return null|int null or 0 if everything went fine, or an error code
76 47
     */
77
    protected function execute(InputInterface $input, OutputInterface $output)
78 47
    {
79 47
        $start = microtime(true);
80
81 47
        $this->setOutput($output);
82
        $this->setVerbosity($output->getVerbosity());
83
84
        if ($input->getOption('child')) {
85 47
            $this->setVerbosity(self::VERBOSITY_CHILD);
86
        }
87 47
88
        $this->getContainer()->get('ez_migration_bundle.step_executed_listener.tracing')->setOutput($output);
89 47
90
        $migrationService = $this->getMigrationService();
91 47
92
        $force = $input->getOption('force');
93 47
94
        $toExecute = $this->buildMigrationsList($input->getOption('path'), $migrationService, $force);
0 ignored issues
show
Bug introduced by
It seems like $input->getOption('path') can also be of type boolean and string; however, parameter $paths of Kaliop\eZMigrationBundle...::buildMigrationsList() does only seem to accept string[], 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

94
        $toExecute = $this->buildMigrationsList(/** @scrutinizer ignore-type */ $input->getOption('path'), $migrationService, $force);
Loading history...
95
96
        if (!count($toExecute)) {
97
            $output->writeln('<info>No migrations to execute</info>');
98 47
            return 0;
99
        }
100
101 47
        $this->printMigrationsList($toExecute, $input, $output);
102
103
        // ask user for confirmation to make changes
104
        if (!$this->askForConfirmation($input, $output)) {
105 47
            return 0;
106
        }
107
108
        if ($input->getOption('separate-process')) {
109
            $builder = new ProcessBuilder();
110
            $executableFinder = new PhpExecutableFinder();
111
            if (false !== $php = $executableFinder->find()) {
112
                $builder->setPrefix($php);
113
            }
114 47
            $builderArgs = $this->createChildProcessArgs($input);
115 47
        }
116 47
117
        $forceSigChild = $input->getOption('force-sigchild-handling');
118
119 47
        $executed = 0;
120
        $failed = 0;
121
        $skipped = 0;
122 47
123 9
        /** @var MigrationDefinition $migrationDefinition */
124 9
        foreach ($toExecute as $name => $migrationDefinition) {
125 9
126
            // let's skip migrations that we know are invalid - user was warned and he decided to proceed anyway
127
            if ($migrationDefinition->status == MigrationDefinition::STATUS_INVALID) {
128 38
                $output->writeln("<comment>Skipping $name</comment>\n");
129
                $skipped++;
130 38
                continue;
131
            }
132
133
            $this->writeln("<info>Processing $name</info>");
134
135
            if ($input->getOption('separate-process')) {
136
137
                try {
138
                    $this->executeMigrationInSeparateProcess($migrationDefinition, $migrationService, $builder, $builderArgs, true, $forceSigChild);
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $builderArgs does not seem to be defined for all execution paths leading up to this point.
Loading history...
Comprehensibility Best Practice introduced by
The variable $builder does not seem to be defined for all execution paths leading up to this point.
Loading history...
139
140
                    $executed++;
141
                } catch (\Exception $e) {
142
                    if ($input->getOption('ignore-failures')) {
143
                        $output->writeln("\n<error>Migration failed! Reason: " . $e->getMessage() . "</error>\n");
144
                        $failed++;
145
                        continue;
146
                    }
147
                    if ($e instanceof AfterMigrationExecutionException) {
148
                        $output->writeln("\n<error>Failure after migration end! Reason: " . $e->getMessage() . "</error>");
149
                    } else {
150
                        $output->writeln("\n<error>Migration aborted! Reason: " . $e->getMessage() . "</error>");
151
                    }
152
                    return 1;
153 38
                }
154
155 30
            } else {
156 8
157 8
                try {
158
                    $this->executeMigrationInProcess($migrationDefinition, $force, $migrationService, $input);
159
160
                    $executed++;
161
                } catch (\Exception $e) {
162 8
                    if ($input->getOption('ignore-failures')) {
163 38
                        $output->writeln("\n<error>Migration failed! Reason: " . $e->getMessage() . "</error>\n");
164
                        $failed++;
165
                        continue;
166
                    }
167
                    $output->writeln("\n<error>Migration aborted! Reason: " . $e->getMessage() . "</error>");
168
                    return 1;
169 39
                }
170
171
            }
172
        }
173
174
        if ($input->getOption('clear-cache')) {
175 39
            $command = $this->getApplication()->find('cache:clear');
176 39
            $inputArray = new ArrayInput(array('command' => 'cache:clear'));
177 39
            $command->run($inputArray, $output);
178
        }
179
180
        $time = microtime(true) - $start;
181 39
        $this->writeln("Executed $executed migrations, failed $failed, skipped $skipped");
182
        if ($input->getOption('separate-process')) {
183 39
            // in case of using subprocesses, we can not measure max memory used
184
            $this->writeln("Time taken: ".sprintf('%.2f', $time)." secs");
185 38
        } else {
186
            $this->writeln("Time taken: ".sprintf('%.2f', $time)." secs, memory: ".sprintf('%.2f', (memory_get_peak_usage(true) / 1000000)). ' MB');
187 38
        }
188 38
    }
189 38
190 38
    /**
191 38
     * @param MigrationDefinition $migrationDefinition
192 38
     * @param bool $force
193
     * @param MigrationService $migrationService
194 30
     * @param InputInterface $input
195
     */
196
    protected function executeMigrationInProcess($migrationDefinition, $force, $migrationService, $input)
197
    {
198
        $migrationService->executeMigration(
199
            $migrationDefinition,
200
            !$input->getOption('no-transactions'),
201
            $input->getOption('default-language'),
0 ignored issues
show
Bug introduced by
It seems like $input->getOption('default-language') can also be of type string[]; however, parameter $defaultLanguageCode of Kaliop\eZMigrationBundle...ice::executeMigration() does only seem to accept string, 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

201
            /** @scrutinizer ignore-type */ $input->getOption('default-language'),
Loading history...
202
            $input->getOption('admin-login'),
203
            $force,
204
            $input->getOption('force-sigchild-handling')
205
        );
206
    }
207
208
    /**
209
     * @param MigrationDefinition $migrationDefinition
210
     * @param MigrationService $migrationService
211
     * @param ProcessBuilder $builder
212
     * @param array $builderArgs
213
     * @param bool $feedback
214
     * @param null|bool $forceSigchild
215
     */
216
    protected function executeMigrationInSeparateProcess($migrationDefinition, $migrationService, $builder, $builderArgs, $feedback = true, $forceSigchild = null)
217
    {
218
        $process = $builder
219
            ->setArguments(array_merge($builderArgs, array('--path=' . $migrationDefinition->path)))
220
            ->getProcess();
221
222
        if ($feedback) {
223
            $this->writeln('<info>Executing: ' . $process->getCommandLine() . '</info>', OutputInterface::VERBOSITY_VERBOSE);
224
        }
225
226
        // allow long migrations processes by default
227
        $process->setTimeout($this->processTimeout);
228
        // allow forcing handling of sigchild. Useful on eg. Debian and Ubuntu
229
        if ($forceSigchild !== null) {
230
            $process->setEnhanceSigchildCompatibility($forceSigchild);
231
        }
232
        // and give immediate feedback to the user
233
        $process->run(
234
            $feedback ?
235
                function($type, $buffer) {
236
                    echo $buffer;
237
                }
238
                :
239
                function($type, $buffer) {
0 ignored issues
show
Unused Code introduced by
The parameter $type is not used and could be removed. ( Ignorable by Annotation )

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

239
                function(/** @scrutinizer ignore-unused */ $type, $buffer) {

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
Unused Code introduced by
The parameter $buffer is not used and could be removed. ( Ignorable by Annotation )

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

239
                function($type, /** @scrutinizer ignore-unused */ $buffer) {

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
240
                }
241
        );
242
243
        if (!$process->isSuccessful()) {
244
            $errorOutput = $process->getErrorOutput();
245
            if ($errorOutput === '') {
246
                $errorOutput = "(separate process used to execute migration failed with no stderr output. Its exit code was: " . $process->getExitCode();
247
                if ($process->getExitCode() == -1) {
248
                    $errorOutput .= ". If you are using Debian or Ubuntu linux, please consider using the --force-sigchild-handling option.";
249
                }
250
                $errorOutput .= ")";
251
            }
252
            /// @todo should we always add the exit code, even when $errorOutput is not null ?
253 47
            throw new \Exception($errorOutput);
254
        }
255 47
256 47
        // There are cases where the separate process dies halfway but does not return a non-zero code.
257
        // That's why we double-check here if the migration is still tagged as 'started'...
258 47
        /** @var Migration $migration */
259 47
        $migration = $migrationService->getMigration($migrationDefinition->name);
260
261
        if (!$migration) {
0 ignored issues
show
introduced by
$migration is of type Kaliop\eZMigrationBundle\API\Value\Migration, thus it always evaluated to true.
Loading history...
262
            // q: shall we add the migration to the db as failed? In doubt, we let it become a ghost, disappeared without a trace...
263
            throw new \Exception("After the separate process charged to execute the migration finished, the migration can not be found in the database any more.");
264 47
        } else if ($migration->status == Migration::STATUS_STARTED) {
265 47
            $errorMsg = "The separate process charged to execute the migration left it in 'started' state. Most likely it died halfway through execution.";
266 47
            $migrationService->endMigration(New Migration(
267 47
                $migration->name,
268
                $migration->md5,
269
                $migration->path,
270
                $migration->executionDate,
271
                Migration::STATUS_FAILED,
272
                ($migration->executionError != '' ? ($errorMsg . ' ' . $migration->executionError) : $errorMsg)
273 47
            ));
274
            throw new \Exception($errorMsg);
275
        }
276
    }
277
278
    /**
279
     * @param string[] $paths
280
     * @param MigrationService $migrationService
281
     * @param bool $force when true, look not only for TODO migrations, but also DONE, SKIPPED, FAILED ones (we still omit STARTED and SUSPENDED ones)
282
     * @return MigrationDefinition[]
283
     *
284
     * @todo this does not scale well with many definitions or migrations
285
     */
286
    protected function buildMigrationsList($paths, $migrationService, $force = false)
287 47
    {
288
        $migrationDefinitions = $migrationService->getMigrationsDefinitions($paths);
289 47
        $migrations = $migrationService->getMigrations();
290
291
        $allowedStatuses = array(Migration::STATUS_TODO);
292
        if ($force) {
293
            $allowedStatuses = array_merge($allowedStatuses, array(Migration::STATUS_DONE, Migration::STATUS_FAILED, Migration::STATUS_SKIPPED));
294
        }
295
296
        // filter away all migrations except 'to do' ones
297
        $toExecute = array();
298
        foreach ($migrationDefinitions as $name => $migrationDefinition) {
299 47
            if (!isset($migrations[$name]) || (($migration = $migrations[$name]) && in_array($migration->status, $allowedStatuses))) {
300
                $toExecute[$name] = $migrationService->parseMigrationDefinition($migrationDefinition);
301 47
            }
302 47
        }
303 47
304 47
        // if user wants to execute 'all' migrations: look for some which are registered in the database even if not
305 47
        // found by the loader
306 9
        if (empty($paths)) {
307
            foreach ($migrations as $migration) {
308 47
                if (in_array($migration->status, $allowedStatuses) && !isset($toExecute[$migration->name])) {
309 47
                    $migrationDefinitions = $migrationService->getMigrationsDefinitions(array($migration->path));
310 47
                    if (count($migrationDefinitions)) {
311 47
                        $migrationDefinition = reset($migrationDefinitions);
312
                        $toExecute[$migration->name] = $migrationService->parseMigrationDefinition($migrationDefinition);
313
                    } else {
314
                        // q: shall we raise a warning here ?
315 47
                    }
316 47
                }
317
            }
318 47
        }
319 47
320 47
        ksort($toExecute);
321
322
        return $toExecute;
323 47
    }
324 47
325
    /**
326 47
     * @param MigrationDefinition[] $toExecute
327
     * @param InputInterface $input
328 47
     * @param OutputInterface $output
329
     *
330
     * @todo use a more compact output when there are *many* migrations
331
     */
332
    protected function printMigrationsList($toExecute, InputInterface $input, OutputInterface $output)
333
    {
334
        $data = array();
335
        $i = 1;
336
        foreach ($toExecute as $name => $migrationDefinition) {
337
            $notes = '';
338
            if ($migrationDefinition->status != MigrationDefinition::STATUS_PARSED) {
339
                $notes = '<error>' . $migrationDefinition->parsingError . '</error>';
340 47
            }
341 47
            $data[] = array(
342
                $i++,
343
                $name,
344
                $notes
345 47
            );
346
        }
347
348
        if (!$input->getOption('child')) {
349
            $table = new Table($output);
350
            $table
351
                ->setHeaders(array('#', 'Migration', 'Notes'))
352
                ->setRows($data);
353 47
            $table->render();
354
        }
355 47
356 47
        $this->writeln('');
357
    }
358 47
359
    protected function askForConfirmation(InputInterface $input, OutputInterface $output, $nonIteractiveOutput = "=============================================\n")
360 47
    {
361
        if ($input->isInteractive() && !$input->getOption('no-interaction')) {
362 47
            $dialog = $this->getHelperSet()->get('question');
363 47
            if (!$dialog->ask(
0 ignored issues
show
Bug introduced by
The method ask() does not exist on Symfony\Component\Console\Helper\Helper. It seems like you code against a sub-type of Symfony\Component\Console\Helper\Helper such as Symfony\Component\Console\Helper\QuestionHelper or Symfony\Component\Console\Helper\DialogHelper. ( Ignorable by Annotation )

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

363
            if (!$dialog->/** @scrutinizer ignore-call */ ask(
Loading history...
364
                $input,
365 47
                $output,
366
                new ConfirmationQuestion('<question>Careful, the database will be modified. Do you want to continue Y/N ?</question>', false)
367 47
            )
368 47
            ) {
369
                $output->writeln('<error>Migration execution cancelled!</error>');
370
                return 0;
371
            }
372
        } else {
373
            if ($nonIteractiveOutput != '') {
374
                $this->writeln("$nonIteractiveOutput");
375
            }
376
        }
377
378
        return 1;
379
    }
380
381
    /**
382
     * Small tricks to allow us to lower verbosity between NORMAL and QUIET and have a decent writeln API, even with old SF versions
383
     * @param $message
384
     * @param int $verbosity
385
     */
386
    protected function writeln($message, $verbosity = OutputInterface::VERBOSITY_NORMAL)
387
    {
388
        if ($this->verbosity >= $verbosity) {
389
            $this->output->writeln($message);
390
        }
391
    }
392
393
    protected function setOutput(OutputInterface $output)
394
    {
395
        $this->output = $output;
396
    }
397
398
    protected function setVerbosity($verbosity)
399
    {
400
        $this->verbosity = $verbosity;
401
    }
402
403
    /**
404
     * Returns the command-line arguments needed to execute a migration in a separate subprocess (omitting 'path')
405
     * @param InputInterface $input
406
     * @return array
407
     */
408
    protected function createChildProcessArgs(InputInterface $input)
409
    {
410
        $kernel = $this->getContainer()->get('kernel');
411
412
        // mandatory args and options
413
        $builderArgs = array(
414
            $_SERVER['argv'][0], // sf console
415
            self::COMMAND_NAME, // name of sf command. Can we get it from the Application instead of hardcoding?
416
            '--env=' . $kernel->getEnvironment(), // sf env
417
            '--child'
418
        );
419
        // sf/ez env options
420
        if (!$kernel->isDebug()) {
421
            $builderArgs[] = '--no-debug';
422
        }
423
        if ($input->getOption('siteaccess')) {
424
            $builderArgs[]='--siteaccess='.$input->getOption('siteaccess');
425
        }
426
        // 'optional' options
427
        // note: options 'clear-cache', 'ignore-failures', 'no-interaction', 'path' and 'separate-process' we never propagate
428
        if ($input->getOption('admin-login')) {
429
            $builderArgs[] = '--admin-login=' . $input->getOption('admin-login');
430
        }
431
        if ($input->getOption('default-language')) {
432
            $builderArgs[] = '--default-language=' . $input->getOption('default-language');
433
        }
434
        if ($input->getOption('force')) {
435
            $builderArgs[] = '--force';
436
        }
437
        if ($input->getOption('no-transactions')) {
438
            $builderArgs[] = '--no-transactions';
439
        }
440
        // useful in case the subprocess has a migration step of type process/run
441
        if ($input->getOption('force-sigchild-handling')) {
442
            $builderArgs[] = '--force-sigchild-handling';
443
        }
444
445
        return $builderArgs;
446
    }
447
}
448