Passed
Push — master ( abce15...80de3b )
by Gaetano
10:06
created

MigrateCommand::execute()   F

Complexity

Conditions 18
Paths 340

Size

Total Lines 123
Code Lines 72

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 39
CRAP Score 47.6624

Importance

Changes 0
Metric Value
cc 18
eloc 72
nc 340
nop 2
dl 0
loc 123
ccs 39
cts 71
cp 0.5493
crap 47.6624
rs 2.2833
c 0
b 0
f 0

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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

98
        $toExecute = $this->buildMigrationsList(/** @scrutinizer ignore-type */ $input->getOption('path'), $migrationService, $force);
Loading history...
99
100
        if (!count($toExecute)) {
101 47
            $output->writeln('<info>No migrations to execute</info>');
102
            return 0;
103
        }
104
105 47
        $this->printMigrationsList($toExecute, $input, $output);
106
107
        // ask user for confirmation to make changes
108
        if (!$this->askForConfirmation($input, $output)) {
109
            return 0;
110
        }
111
112
        if ($input->getOption('separate-process')) {
113
            $builder = new ProcessBuilder();
114 47
            $executableFinder = new PhpExecutableFinder();
115 47
            if (false !== $php = $executableFinder->find()) {
116 47
                $builder->setPrefix($php);
117
            }
118
            $builderArgs = $this->createChildProcessArgs($input);
119 47
        }
120
121
        // allow forcing handling of sigchild. Useful on eg. Debian and Ubuntu
122 47
        if ($input->getOption('force-sigchild-enabled')) {
123 9
            Process::forceSigchildEnabled(true);
124 9
        }
125 9
126
        $executed = 0;
127
        $failed = 0;
128 38
        $skipped = 0;
129
130 38
        /** @var MigrationDefinition $migrationDefinition */
131
        foreach ($toExecute as $name => $migrationDefinition) {
132
133
            // let's skip migrations that we know are invalid - user was warned and he decided to proceed anyway
134
            if ($migrationDefinition->status == MigrationDefinition::STATUS_INVALID) {
135
                $output->writeln("<comment>Skipping $name</comment>\n");
136
                $skipped++;
137
                continue;
138
            }
139
140
            $this->writeln("<info>Processing $name</info>");
141
142
            if ($input->getOption('separate-process')) {
143
144
                try {
145
                    /// @todo in quiet mode, we could suppress output of the sub-command...
146
                    $this->executeMigrationInSeparateProcess($migrationDefinition, $migrationService, $builder, $builderArgs, true);
0 ignored issues
show
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...
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...
147
148
                    $executed++;
149
                } catch (\Exception $e) {
150
                    if ($input->getOption('ignore-failures')) {
151
                        $this->errOutput->writeln("\n<error>Migration failed! Reason: " . $e->getMessage() . "</error>\n");
152
                        $failed++;
153 38
                        continue;
154
                    }
155 30
                    if ($e instanceof AfterMigrationExecutionException) {
156 8
                        $this->errOutput->writeln("\n<error>Failure after migration end! Reason: " . $e->getMessage() . "</error>");
157 8
                    } else {
158
159
                        $errorMessage = $e->getMessage();
160
                        if ($errorMessage == $this->subProcessErrorString) {
161
                            // we have already echoed the error message while the subprocess was executing
162 8
                            $errorMessage = "see above";
163 38
                        } else {
164
                            $errorMessage = preg_replace('/^\n*Migration aborted! Reason: */', '', $e->getMessage());
165
                        }
166
167
                        $this->errOutput->writeln("\n<error>Migration aborted! Reason: " . $errorMessage . "</error>");
168
                    }
169 39
                    return 1;
170
                }
171
172
            } else {
173
174
                try {
175 39
                    $this->executeMigrationInProcess($migrationDefinition, $force, $migrationService, $input);
176 39
177 39
                    $executed++;
178
                } catch (\Exception $e) {
179
                    if ($input->getOption('ignore-failures')) {
180
                        $this->errOutput->writeln("\n<error>Migration failed! Reason: " . $e->getMessage() . "</error>\n");
181 39
                        $failed++;
182
                        continue;
183 39
                    }
184
                    $this->errOutput->writeln("\n<error>Migration aborted! Reason: " . $e->getMessage() . "</error>");
185 38
                    return 1;
186
                }
187 38
188 38
            }
189 38
        }
190 38
191 38
        if ($input->getOption('clear-cache')) {
192 38
            $command = $this->getApplication()->find('cache:clear');
193
            $inputArray = new ArrayInput(array('command' => 'cache:clear'));
194 30
            $command->run($inputArray, $output);
195
        }
196
197
        $time = microtime(true) - $start;
198
        $this->writeln("Executed $executed migrations, failed $failed, skipped $skipped");
199
        if ($input->getOption('separate-process')) {
200
            // in case of using subprocesses, we can not measure max memory used
201
            $this->writeln("Time taken: ".sprintf('%.2f', $time)." secs");
202
        } else {
203
            $this->writeln("Time taken: ".sprintf('%.2f', $time)." secs, memory: ".sprintf('%.2f', (memory_get_peak_usage(true) / 1000000)). ' MB');
204
        }
205
    }
206
207
    /**
208
     * @param MigrationDefinition $migrationDefinition
209
     * @param bool $force
210
     * @param MigrationService $migrationService
211
     * @param InputInterface $input
212
     */
213
    protected function executeMigrationInProcess($migrationDefinition, $force, $migrationService, $input)
214
    {
215
        $migrationService->executeMigration(
216
            $migrationDefinition,
217
            !$input->getOption('no-transactions'),
218
            $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

218
            /** @scrutinizer ignore-type */ $input->getOption('default-language'),
Loading history...
219
            $input->getOption('admin-login'),
220
            $force
221
        );
222
    }
223
224
    /**
225
     * @param MigrationDefinition $migrationDefinition
226
     * @param MigrationService $migrationService
227
     * @param ProcessBuilder $builder
228
     * @param array $builderArgs
229
     * @param bool $feedback
230
     */
231
    protected function executeMigrationInSeparateProcess($migrationDefinition, $migrationService, $builder, $builderArgs, $feedback = true)
232
    {
233
        $process = $builder
234
            ->setArguments(array_merge($builderArgs, array('--path=' . $migrationDefinition->path)))
235
            ->getProcess();
236
237
        if ($feedback) {
238
            $this->writeln('<info>Executing: ' . $process->getCommandLine() . '</info>', OutputInterface::VERBOSITY_VERBOSE);
239
        }
240
241
        $this->subProcessErrorString = '';
242
243
        // allow long migrations processes by default
244
        $process->setTimeout($this->subProcessTimeout);
245
246
        // and give immediate feedback to the user...
247
        // NB: if the subprocess writes to stderr then terminates with non-0 exit code, this will lead us to echoing the
248
        // error text twice, once here and once at the end of execution of this command.
249
        // In order to avoid that, since we can not know at this time what the subprocess exit code will be, we should
250
        // somehow still print the error text now, and compare it to what we gt at the end...
251
        $process->run(
252
            $feedback ?
253 47
                function($type, $buffer) {
254
                    if ($type == 'err') {
255 47
                        $this->subProcessErrorString .= $buffer;
256 47
                        $this->errOutput->write(preg_replace('/^\n*Migration aborted! Reason: */', '', $buffer), OutputInterface::OUTPUT_RAW);
0 ignored issues
show
Bug introduced by
Symfony\Component\Consol...utInterface::OUTPUT_RAW of type integer is incompatible with the type boolean expected by parameter $newline of Symfony\Component\Consol...utputInterface::write(). ( Ignorable by Annotation )

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

256
                        $this->errOutput->write(preg_replace('/^\n*Migration aborted! Reason: */', '', $buffer), /** @scrutinizer ignore-type */ OutputInterface::OUTPUT_RAW);
Loading history...
257
                    } else {
258 47
                        $this->output->write($buffer, OutputInterface::OUTPUT_RAW);
259 47
                    }
260
                }
261
                :
262
                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

262
                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

262
                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...
263
                }
264 47
        );
265 47
266 47
        if (!$process->isSuccessful()) {
267 47
            $errorOutput = $process->getErrorOutput();
268
            /// @todo should we always add the exit code to the error message, even when $errorOutput is not null ?
269
            if ($errorOutput === '') {
270
                $errorOutput = "(separate process used to execute migration failed with no stderr output. Its exit code was: " . $process->getExitCode();
271
                if ($process->getExitCode() == -1) {
272
                    $errorOutput .= ". If you are using Debian or Ubuntu linux, please consider using the --force-sigchild-enabled option.";
273 47
                }
274
                $errorOutput .= ")";
275
            }
276
            throw new \Exception($errorOutput);
277
        }
278
279
        // There are cases where the separate process dies halfway but does not return a non-zero code.
280
        // That's why we double-check here if the migration is still tagged as 'started'...
281
        /** @var Migration $migration */
282
        $migration = $migrationService->getMigration($migrationDefinition->name);
283
284
        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...
285
            // q: shall we add the migration to the db as failed? In doubt, we let it become a ghost, disappeared without a trace...
286
            throw new \Exception("After the separate process charged to execute the migration finished, the migration can not be found in the database any more.");
287 47
        } else if ($migration->status == Migration::STATUS_STARTED) {
288
            $errorMsg = "The separate process charged to execute the migration left it in 'started' state. Most likely it died halfway through execution.";
289 47
            $migrationService->endMigration(New Migration(
290
                $migration->name,
291
                $migration->md5,
292
                $migration->path,
293
                $migration->executionDate,
294
                Migration::STATUS_FAILED,
295
                ($migration->executionError != '' ? ($errorMsg . ' ' . $migration->executionError) : $errorMsg)
296
            ));
297
            throw new \Exception($errorMsg);
298
        }
299 47
    }
300
301 47
    /**
302 47
     * @param string[] $paths
303 47
     * @param MigrationService $migrationService
304 47
     * @param bool $force when true, look not only for TODO migrations, but also DONE, SKIPPED, FAILED ones (we still omit STARTED and SUSPENDED ones)
305 47
     * @return MigrationDefinition[]
306 9
     *
307
     * @todo this does not scale well with many definitions or migrations
308 47
     */
309 47
    protected function buildMigrationsList($paths, $migrationService, $force = false)
310 47
    {
311 47
        $migrationDefinitions = $migrationService->getMigrationsDefinitions($paths);
312
        $migrations = $migrationService->getMigrations();
313
314
        $allowedStatuses = array(Migration::STATUS_TODO);
315 47
        if ($force) {
316 47
            $allowedStatuses = array_merge($allowedStatuses, array(Migration::STATUS_DONE, Migration::STATUS_FAILED, Migration::STATUS_SKIPPED));
317
        }
318 47
319 47
        // filter away all migrations except 'to do' ones
320 47
        $toExecute = array();
321
        foreach ($migrationDefinitions as $name => $migrationDefinition) {
322
            if (!isset($migrations[$name]) || (($migration = $migrations[$name]) && in_array($migration->status, $allowedStatuses))) {
323 47
                $toExecute[$name] = $migrationService->parseMigrationDefinition($migrationDefinition);
324 47
            }
325
        }
326 47
327
        // if user wants to execute 'all' migrations: look for some which are registered in the database even if not
328 47
        // found by the loader
329
        if (empty($paths)) {
330
            foreach ($migrations as $migration) {
331
                if (in_array($migration->status, $allowedStatuses) && !isset($toExecute[$migration->name])) {
332
                    $migrationDefinitions = $migrationService->getMigrationsDefinitions(array($migration->path));
333
                    if (count($migrationDefinitions)) {
334
                        $migrationDefinition = reset($migrationDefinitions);
335
                        $toExecute[$migration->name] = $migrationService->parseMigrationDefinition($migrationDefinition);
336
                    } else {
337
                        // q: shall we raise a warning here ?
338
                    }
339
                }
340 47
            }
341 47
        }
342
343
        ksort($toExecute);
344
345 47
        return $toExecute;
346
    }
347
348
    /**
349
     * @param MigrationDefinition[] $toExecute
350
     * @param InputInterface $input
351
     * @param OutputInterface $output
352
     *
353 47
     * @todo use a more compact output when there are *many* migrations
354
     */
355 47
    protected function printMigrationsList($toExecute, InputInterface $input, OutputInterface $output)
356 47
    {
357
        $data = array();
358 47
        $i = 1;
359
        foreach ($toExecute as $name => $migrationDefinition) {
360 47
            $notes = '';
361
            if ($migrationDefinition->status != MigrationDefinition::STATUS_PARSED) {
362 47
                $notes = '<error>' . $migrationDefinition->parsingError . '</error>';
363 47
            }
364
            $data[] = array(
365 47
                $i++,
366
                $name,
367 47
                $notes
368 47
            );
369
        }
370
371
        if (!$input->getOption('child')) {
372
            $table = new Table($output);
373
            $table
374
                ->setHeaders(array('#', 'Migration', 'Notes'))
375
                ->setRows($data);
376
            $table->render();
377
        }
378
379
        $this->writeln('');
380
    }
381
382
    protected function askForConfirmation(InputInterface $input, OutputInterface $output, $nonIteractiveOutput = "=============================================\n")
383
    {
384
        if ($input->isInteractive() && !$input->getOption('no-interaction')) {
385
            $dialog = $this->getHelperSet()->get('question');
386
            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

386
            if (!$dialog->/** @scrutinizer ignore-call */ ask(
Loading history...
387
                $input,
388
                $output,
389
                new ConfirmationQuestion('<question>Careful, the database will be modified. Do you want to continue Y/N ?</question>', false)
390
            )
391
            ) {
392
                $output->writeln('<error>Migration execution cancelled!</error>');
393
                return 0;
394
            }
395
        } else {
396
            if ($nonIteractiveOutput != '') {
397
                $this->writeln("$nonIteractiveOutput");
398
            }
399
        }
400
401
        return 1;
402
    }
403
404
    /**
405
     * Returns the command-line arguments needed to execute a migration in a separate subprocess (omitting 'path')
406
     * @param InputInterface $input
407
     * @return array
408
     */
409
    protected function createChildProcessArgs(InputInterface $input)
410
    {
411
        $kernel = $this->getContainer()->get('kernel');
412
413
        // mandatory args and options
414
        $builderArgs = array(
415
            $_SERVER['argv'][0], // sf console
416
            self::COMMAND_NAME, // name of sf command. Can we get it from the Application instead of hardcoding?
417
            '--env=' . $kernel->getEnvironment(), // sf env
418
            '--child'
419
        );
420
        // sf/ez env options
421
        if (!$kernel->isDebug()) {
422
            $builderArgs[] = '--no-debug';
423
        }
424
        if ($input->getOption('siteaccess')) {
425
            $builderArgs[]='--siteaccess='.$input->getOption('siteaccess');
426
        }
427
        // 'optional' options
428
        // note: options 'clear-cache', 'ignore-failures', 'no-interaction', 'path' and 'separate-process' we never propagate
429
        if ($input->getOption('admin-login')) {
430
            $builderArgs[] = '--admin-login=' . $input->getOption('admin-login');
431
        }
432
        if ($input->getOption('default-language')) {
433
            $builderArgs[] = '--default-language=' . $input->getOption('default-language');
434
        }
435
        if ($input->getOption('force')) {
436
            $builderArgs[] = '--force';
437
        }
438
        if ($input->getOption('no-transactions')) {
439
            $builderArgs[] = '--no-transactions';
440
        }
441
        // useful in case the subprocess has a migration step of type process/run
442
        if ($input->getOption('force-sigchild-enabled')) {
443
            $builderArgs[] = '--force-sigchild-enabled';
444
        }
445
446
        return $builderArgs;
447
    }
448
}
449