Completed
Push — master ( e5ee14...111d0a )
by Gaetano
07:17
created

MigrationService::skipMigration()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 1
CRAP Score 1

Importance

Changes 0
Metric Value
dl 0
loc 4
c 0
b 0
f 0
ccs 1
cts 1
cp 1
rs 10
cc 1
eloc 2
nc 1
nop 1
crap 1
1
<?php
2
3
namespace Kaliop\eZMigrationBundle\Core;
4
5
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
6
use eZ\Publish\API\Repository\Repository;
7
use Kaliop\eZMigrationBundle\API\Collection\MigrationDefinitionCollection;
8
use Kaliop\eZMigrationBundle\API\LanguageAwareInterface;
9
use Kaliop\eZMigrationBundle\API\StorageHandlerInterface;
10
use Kaliop\eZMigrationBundle\API\LoaderInterface;
11
use Kaliop\eZMigrationBundle\API\DefinitionParserInterface;
12
use Kaliop\eZMigrationBundle\API\ExecutorInterface;
13
use Kaliop\eZMigrationBundle\API\Value\Migration;
14
use Kaliop\eZMigrationBundle\API\Value\MigrationDefinition;
15
use Kaliop\eZMigrationBundle\API\Exception\MigrationStepExecutionException;
16
use Kaliop\eZMigrationBundle\API\Event\BeforeStepExecutionEvent;
17
use Kaliop\eZMigrationBundle\API\Event\StepExecutedEvent;
18
19
class MigrationService
20
{
21
    use RepositoryUserSetterTrait;
22
23
    /**
24
     * Constant defining the default Admin user ID.
25
     * @todo inject via config parameter
26
     */
27
    const ADMIN_USER_ID = 14;
28
29
    /**
30
     * @var LoaderInterface $loader
31
     */
32
    protected $loader;
33
    /**
34
     * @var StorageHandlerInterface $storageHandler
35
     */
36
    protected $storageHandler;
37
38
    /** @var DefinitionParserInterface[] $DefinitionParsers */
39
    protected $DefinitionParsers = array();
40 20
41 5
    /** @var ExecutorInterface[] $executors */
42 20
    protected $executors = array();
43 20
44 20
    protected $repository;
45 20
46 20
    protected $dispatcher;
47
48 20
    public function __construct(LoaderInterface $loader, StorageHandlerInterface $storageHandler, Repository $repository, EventDispatcherInterface $eventDispatcher)
49
    {
50 20
        $this->loader = $loader;
51 20
        $this->storageHandler = $storageHandler;
52
        $this->repository = $repository;
53 20
        $this->dispatcher = $eventDispatcher;
54
    }
55 20
56 20
    public function addDefinitionParser(DefinitionParserInterface $DefinitionParser)
57 20
    {
58 20
        $this->DefinitionParsers[] = $DefinitionParser;
59
    }
60
61
    public function addExecutor(ExecutorInterface $executor)
62
    {
63
        foreach($executor->supportedTypes() as $type) {
64
            $this->executors[$type] = $executor;
65
        }
66 20
    }
67
68
    /**
69 20
     * NB: returns UNPARSED definitions
70 20
     *
71 20
     * @param string[] $paths
72 20
     * @return MigrationDefinitionCollection key: migration name, value: migration definition as binary string
73 20
     */
74 20
    public function getMigrationsDefinitions(array $paths = array())
75 20
    {
76 20
        // we try to be flexible in file types we support, and the same time avoid loading all files in a directory
77
        $handledDefinitions = array();
78
        foreach($this->loader->listAvailableDefinitions($paths) as $migrationName => $definitionPath) {
79 20
            foreach($this->DefinitionParsers as $definitionParser) {
80
                if ($definitionParser->supports($migrationName)) {
81
                    $handledDefinitions[] = $definitionPath;
82
                }
83 20
            }
84 2
        }
85
86
        // we can not call loadDefinitions with an empty array using the Filesystem loader, or it will start looking in bundles...
87
        if (empty($handledDefinitions) && !empty($paths)) {
88
            return new MigrationDefinitionCollection();
89
        }
90
91 20
        return $this->loader->loadDefinitions($handledDefinitions);
92
    }
93 20
94
    /**
95
     * Return the list of all the migrations which where executed or attempted so far
96
     *
97
     * @return \Kaliop\eZMigrationBundle\API\Collection\MigrationCollection
98
     */
99
    public function getMigrations()
100 19
    {
101
        return $this->storageHandler->loadMigrations();
102 19
    }
103
104
    /**
105
     * @param string $migrationName
106
     * @return Migration|null
107
     */
108
    public function getMigration($migrationName)
109 19
    {
110
        return $this->storageHandler->loadMigration($migrationName);
111 19
    }
112 19
113
    /**
114
     * @param MigrationDefinition $migrationDefinition
115
     * @return Migration
116
     */
117 19
    public function addMigration(MigrationDefinition $migrationDefinition)
118
    {
119 19
        return $this->storageHandler->addMigration($migrationDefinition);
120
    }
121
122
    /**
123
     * @param Migration $migration
124
     */
125
    public function deleteMigration(Migration $migration)
126
    {
127
        return $this->storageHandler->deleteMigration($migration);
128
    }
129
130 20
    /**
131
     * @param MigrationDefinition $migrationDefinition
132 20
     * @return Migration
133 20
     */
134
    public function skipMigration(MigrationDefinition $migrationDefinition)
135 20
    {
136
        return $this->storageHandler->skipMigration($migrationDefinition);
137
    }
138 20
139 13
    /**
140 1
     * Parses a migration definition, return a parsed definition.
141 1
     * If there is a parsing error, the definition status will be updated accordingly
142 1
     *
143 1
     * @param MigrationDefinition $migrationDefinition
144 1
     * @return MigrationDefinition
145 1
     * @throws \Exception if the migrationDefinition has no suitable parser for its source format
146 1
     */
147 1
    public function parseMigrationDefinition(MigrationDefinition $migrationDefinition)
148
    {
149 20
        foreach($this->DefinitionParsers as $definitionParser) {
150
            if ($definitionParser->supports($migrationDefinition->name)) {
151 20
                // parse the source file
152 12
                $migrationDefinition = $definitionParser->parseMigrationDefinition($migrationDefinition);
153 15
154
                // and make sure we know how to handle all steps
155
                foreach($migrationDefinition->steps as $step) {
156
                    if (!isset($this->executors[$step->type])) {
157
                        return new MigrationDefinition(
158
                            $migrationDefinition->name,
159
                            $migrationDefinition->path,
160
                            $migrationDefinition->rawDefinition,
161
                            MigrationDefinition::STATUS_INVALID,
162
                            array(),
163
                            "Can not handle migration step of type '{$step->type}'"
164
                        );
165
                    }
166 12
                }
167
168 12
                return $migrationDefinition;
169
            }
170
        }
171
172 12
        throw new \Exception("No parser available to parse migration definition '$migrationDefinition'");
173 12
    }
174
175
    /**
176
     * @param MigrationDefinition $migrationDefinition
177 12
     * @param bool $useTransaction when set to false, no repo transaction will be used to wrap the migration
178
     * @param string $defaultLanguageCode
179
     * @throws \Exception
180 12
     *
181
     * @todo add support for skipped migrations, partially executed migrations
182
     */
183
    public function executeMigration(MigrationDefinition $migrationDefinition, $useTransaction = true, $defaultLanguageCode = null)
184
    {
185
        if ($migrationDefinition->status == MigrationDefinition::STATUS_TO_PARSE) {
186 12
            $migrationDefinition = $this->parseMigrationDefinition($migrationDefinition);
187
        }
188 12
189
        if ($migrationDefinition->status == MigrationDefinition::STATUS_INVALID) {
190
            throw new \Exception("Can not execute migration '{$migrationDefinition->name}': {$migrationDefinition->parsingError}");
191
        }
192
193 12
        // Inject default language code in executors that support it.
194
        if ($defaultLanguageCode) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $defaultLanguageCode of type string|null is loosely compared to true; this is ambiguous if the string can be empty. 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 string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
195 12
            foreach ($this->executors as $executor) {
196
                if ($executor instanceof LanguageAwareInterface) {
197 12
                    $executor->setDefaultLanguageCode($defaultLanguageCode);
198
                }
199 12
            }
200
        }
201 12
202
        // set migration as begun - has to be in own db transaction
203 10
        $migration = $this->storageHandler->startMigration($migrationDefinition);
204
205 10
        if ($useTransaction) {
206 10
            $this->repository->beginTransaction();
207
        }
208 9
209
        $previousUserId = null;
210
211 12
        try {
212 9
213 9
            $i = 1;
214 9
215 9
            foreach($migrationDefinition->steps as $step) {
216
                // we validated the fact that we have a good executor at parsing time
217 9
                $executor = $this->executors[$step->type];
218
219 9
                $this->dispatcher->dispatch('ez_migration.before_execution', new BeforeStepExecutionEvent($step, $executor));
220
221
                $result = $executor->execute($step);
222
223
                $this->dispatcher->dispatch('ez_migration.step_executed', new StepExecutedEvent($step, $result));
224
225
                $i++;
226
            }
227
228
            $status = Migration::STATUS_DONE;
229 12
230
            // set migration as done
231 12
            $this->storageHandler->endMigration(new Migration(
232
                $migration->name,
233
                $migration->md5,
234
                $migration->path,
235
                $migration->executionDate,
236
                $status
237
            ));
238
239
            if ($useTransaction) {
240
                try {
241
                    // there might be workflows or other actions happening at commit time that fail if we are not admin
242 3
                    $previousUserId = $this->loginUser(self::ADMIN_USER_ID);
243 3
                    $this->repository->commit();
244 3
                    $this->loginUser($previousUserId);
245 3
                } catch(\RuntimeException $e) {
246 3
                    // At present time, the ez5 repo does not support nested commits. So if some migration step has
247 3
                    // committed already, we get an exception a this point. Extremely poor design, but what can we do ?
248 3
249 3
                    if ($previousUserId) {
250
                        $this->loginUser($previousUserId);
251 12
                    }
252 12
253 12
                    // we update the migration with the info about what just happened
254
                    $warning = 'An exception was thrown while committing, most likely due to some migration step being committed already: ' .
255
                        $this->getFullExceptionMessage($e) . ' in file ' . $e->getFile() . ' line ' . $e->getLine();
256
                    $this->storageHandler->endMigration(
257
                        new Migration(
258
                            $migration->name,
259
                            $migration->md5,
260
                            $migration->path,
261
                            $migration->executionDate,
262 19
                            $status,
263
                            $warning
264 3
                        ),
265 3
                        true
266
                    );
267 3
                }
268 3
            }
269 19
270
        } catch(\Exception $e) {
271
272
             $additionalError = '';
273
274
            if ($useTransaction) {
275
                try {
276
                    // there is no need to become admin here, at least in theory
277
                    $this->repository->rollBack();
278
279
                    // this should not happen, really, but we try to cater to the case where a commit() call above throws
280
                    // an unexpected exception
281
                    if ($previousUserId) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $previousUserId of type null|integer is loosely compared to true; this is ambiguous if the integer can be zero. 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...
282
                        $this->loginUser($previousUserId);
283
                    }
284
285
                } catch(\RuntimeException $e2) {
286
                    // at present time, the ez5 repo does not support nested commits. So if some migration step has
287
                    // committed already, we get an exception a this point. Extremely poor design, but what can we do ?
288
                    $additionalError = '. In addition, an exception was thrown while rolling back, most likely due to some migration step being committed already: ' .
289
                        $this->getFullExceptionMessage($e2) . ' in file ' . $e2->getFile() . ' line ' . $e2->getLine();
290
                } catch(\Exception $e3) {
291
                    $additionalError = '. In addition, an exception was thrown while rolling back: ' .
292
                        $this->getFullExceptionMessage($e3) . ' in file ' . $e3->getFile() . ' line ' . $e3->getLine();
293
                }
294
            }
295
296 3
            $errorMessage = $this->getFullExceptionMessage($e) . ' in file ' . $e->getFile() . ' line ' . $e->getLine() .
297
                $additionalError;
298
299
            // set migration as failed
300
            // NB: we use the 'force' flag here because we might be catching an exception happened during the call to
301
            // $this->repository->commit() above, in which case the Migration might be in the DB with a status 'done'
302
            $this->storageHandler->endMigration(
303
                new Migration(
304
                    $migration->name,
305
                    $migration->md5,
306
                    $migration->path,
307
                    $migration->executionDate,
308
                    Migration::STATUS_FAILED,
309
                    $errorMessage
310
                ),
311
                true
312
            );
313
314
            throw new MigrationStepExecutionException($errorMessage, $i, $e);
315
        }
316
    }
317
318
    /**
319
     * Turns eZPublish cryptic exceptions into something more palatable for random devs
320
     * @todo should this be moved to a lower layer ?
321
     *
322
     * @param \Exception $e
323
     * @return string
324
     */
325
    protected function getFullExceptionMessage(\Exception $e)
326
    {
327
        $message = $e->getMessage();
328
        if (is_a($e, '\eZ\Publish\API\Repository\Exceptions\ContentTypeFieldDefinitionValidationException') ||
329
            //is_a($e, '\eZ\Publish\API\Repository\Exceptions\ContentTypeFieldValidationException') ||
0 ignored issues
show
Unused Code Comprehensibility introduced by
67% of this comment could be valid code. Did you maybe forget this after debugging?

Sometimes obsolete code just ends up commented out instead of removed. In this case it is better to remove the code once you have checked you do not need it.

The code might also have been commented out for debugging purposes. In this case it is vital that someone uncomments it again or your project may behave in very unexpected ways in production.

This check looks for comments that seem to be mostly valid code and reports them.

Loading history...
330
            is_a($e, '\eZ\Publish\API\Repository\Exceptions\LimitationValidationException')
331
        ) {
332
            if (is_a($e, '\eZ\Publish\API\Repository\Exceptions\LimitationValidationException')) {
333
                $errorsArray = $e->getLimitationErrors();
0 ignored issues
show
Bug introduced by
It seems like you code against a specific sub-type and not the parent class Exception as the method getLimitationErrors() does only exist in the following sub-classes of Exception: eZ\Publish\API\Repositor...tionValidationException, eZ\Publish\Core\Base\Exc...tionValidationException. Maybe you want to instanceof check for one of these explicitly?

Let’s take a look at an example:

abstract class User
{
    /** @return string */
    abstract public function getPassword();
}

class MyUser extends User
{
    public function getPassword()
    {
        // return something
    }

    public function getDisplayName()
    {
        // return some name.
    }
}

class AuthSystem
{
    public function authenticate(User $user)
    {
        $this->logger->info(sprintf('Authenticating %s.', $user->getDisplayName()));
        // do something.
    }
}

In the above example, the authenticate() method works fine as long as you just pass instances of MyUser. However, if you now also want to pass a different sub-classes of User which does not have a getDisplayName() method, the code will break.

Available Fixes

  1. Change the type-hint for the parameter:

    class AuthSystem
    {
        public function authenticate(MyUser $user) { /* ... */ }
    }
    
  2. Add an additional type-check:

    class AuthSystem
    {
        public function authenticate(User $user)
        {
            if ($user instanceof MyUser) {
                $this->logger->info(/** ... */);
            }
    
            // or alternatively
            if ( ! $user instanceof MyUser) {
                throw new \LogicException(
                    '$user must be an instance of MyUser, '
                   .'other instances are not supported.'
                );
            }
    
        }
    }
    
Note: PHP Analyzer uses reverse abstract interpretation to narrow down the types inside the if block in such a case.
  1. Add the method to the parent class:

    abstract class User
    {
        /** @return string */
        abstract public function getPassword();
    
        /** @return string */
        abstract public function getDisplayName();
    }
    
Loading history...
334
                if ($errorsArray == null) {
335
                    return $message;
336
                }
337
            } else {
338
                $errorsArray = $e->getFieldErrors();
0 ignored issues
show
Bug introduced by
It seems like you code against a specific sub-type and not the parent class Exception as the method getFieldErrors() does only exist in the following sub-classes of Exception: eZ\Publish\API\Repositor...ieldValidationException, eZ\Publish\API\Repositor...tionValidationException, eZ\Publish\Core\Base\Exc...ieldValidationException, eZ\Publish\Core\Base\Exc...tionValidationException. Maybe you want to instanceof check for one of these explicitly?

Let’s take a look at an example:

abstract class User
{
    /** @return string */
    abstract public function getPassword();
}

class MyUser extends User
{
    public function getPassword()
    {
        // return something
    }

    public function getDisplayName()
    {
        // return some name.
    }
}

class AuthSystem
{
    public function authenticate(User $user)
    {
        $this->logger->info(sprintf('Authenticating %s.', $user->getDisplayName()));
        // do something.
    }
}

In the above example, the authenticate() method works fine as long as you just pass instances of MyUser. However, if you now also want to pass a different sub-classes of User which does not have a getDisplayName() method, the code will break.

Available Fixes

  1. Change the type-hint for the parameter:

    class AuthSystem
    {
        public function authenticate(MyUser $user) { /* ... */ }
    }
    
  2. Add an additional type-check:

    class AuthSystem
    {
        public function authenticate(User $user)
        {
            if ($user instanceof MyUser) {
                $this->logger->info(/** ... */);
            }
    
            // or alternatively
            if ( ! $user instanceof MyUser) {
                throw new \LogicException(
                    '$user must be an instance of MyUser, '
                   .'other instances are not supported.'
                );
            }
    
        }
    }
    
Note: PHP Analyzer uses reverse abstract interpretation to narrow down the types inside the if block in such a case.
  1. Add the method to the parent class:

    abstract class User
    {
        /** @return string */
        abstract public function getPassword();
    
        /** @return string */
        abstract public function getDisplayName();
    }
    
Loading history...
339
            }
340
341
            foreach ($errorsArray as $errors) {
342
                // sometimes error arrays are 2-level deep, sometimes 1...
343
                if (!is_array($errors)) {
344
                    $errors = array($errors);
345
                }
346
                foreach ($errors as $error) {
347
                    /// @todo find out what is the proper eZ way of getting a translated message for these errors
348
                    $translatableMessage = $error->getTranslatableMessage();
349
                    if (is_a($e, 'eZ\Publish\API\Repository\Values\Translation\Plural')) {
350
                        $msgText = $translatableMessage->plural;
351
                    } else {
352
                        $msgText = $translatableMessage->message;
353
                    }
354
355
                    $message .= "\n" . $msgText . " - " . var_export($translatableMessage->values, true);
356
                }
357
            }
358
        }
359
        return $message;
360
    }
361
}
362