Passed
Push — master ( 0cc661...15cf8e )
by Gaetano
09:51
created

Migration::endMigration()   A

Complexity

Conditions 5
Paths 4

Size

Total Lines 51
Code Lines 26

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 21
CRAP Score 5.1777

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 5
eloc 26
c 1
b 0
f 0
nc 4
nop 2
dl 0
loc 51
ccs 21
cts 26
cp 0.8077
crap 5.1777
rs 9.1928

How to fix   Long Method   

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\Core\StorageHandler\Database;
4
5
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
6
use Doctrine\DBAL\Schema\Schema;
7
use eZ\Publish\Core\Persistence\Database\DatabaseHandler;
8
use eZ\Publish\Core\Persistence\Database\QueryException;
9
use eZ\Publish\Core\Persistence\Database\SelectQuery;
10
use Kaliop\eZMigrationBundle\API\StorageHandlerInterface;
11
use Kaliop\eZMigrationBundle\API\Collection\MigrationCollection;
12
use Kaliop\eZMigrationBundle\API\Value\Migration as APIMigration;
13
use Kaliop\eZMigrationBundle\API\Value\MigrationDefinition;
14
15
use Kaliop\eZMigrationBundle\API\ConfigResolverInterface;
16
17
/**
18
 * Database-backed storage for info on executed migrations
19
 *
20
 * @todo replace all usage of the ezcdb api with the doctrine dbal one, so that we only depend on one
21
 */
22
class Migration extends TableStorage implements StorageHandlerInterface
23
{
24
    protected $fieldList = 'migration, md5, path, execution_date, status, execution_error';
25
26
    /**
27
     * @param DatabaseHandler $dbHandler
28
     * @param string $tableNameParameter
29
     * @param ConfigResolverInterface $configResolver
30
     * @param array $tableCreationOptions
31 96
     * @throws \Exception
32
     */
33 96
    public function __construct(DatabaseHandler $dbHandler, $tableNameParameter = 'kaliop_migrations', ConfigResolverInterface $configResolver = null, $tableCreationOptions = array())
34 96
    {
35
        parent::__construct($dbHandler, $tableNameParameter, $configResolver, $tableCreationOptions);
36
    }
37
38
    /**
39
     * @param int $limit
40
     * @param int $offset
41 93
     * @return MigrationCollection
42
     */
43 93
    public function loadMigrations($limit = null, $offset = null)
44
    {
45
        return $this->loadMigrationsInner(null, $limit, $offset);
46
    }
47
48
    /**
49
     * @param int $status
50
     * @param int $limit
51
     * @param int $offset
52 1
     * @return MigrationCollection
53
     */
54 1
    public function loadMigrationsByStatus($status, $limit = null, $offset = null)
55
    {
56
        return $this->loadMigrationsInner($status, $limit, $offset);
57
    }
58
59
    /**
60
     * @param int $status
61
     * @param int $limit
62
     * @param int $offset
63 93
     * @return MigrationCollection
64
     */
65 93
    protected function loadMigrationsInner($status = null, $limit = null, $offset = null)
66
    {
67
        $this->createTableIfNeeded();
68 93
69 93
        /** @var \eZ\Publish\Core\Persistence\Database\SelectQuery $q */
70 93
        $q = $this->dbHandler->createSelectQuery();
71 93
        $q->select($this->fieldList)
0 ignored issues
show
Unused Code introduced by
The call to eZ\Publish\Core\Persiste...e\SelectQuery::select() has too many arguments starting with $this->fieldList. ( Ignorable by Annotation )

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

71
        $q->/** @scrutinizer ignore-call */ 
72
            select($this->fieldList)

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress. Please note the @ignore annotation hint above.

Loading history...
72 93
            ->from($this->tableName)
0 ignored issues
show
Unused Code introduced by
The call to eZ\Publish\Core\Persiste...ase\SelectQuery::from() has too many arguments starting with $this->tableName. ( Ignorable by Annotation )

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

72
            ->/** @scrutinizer ignore-call */ from($this->tableName)

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress. Please note the @ignore annotation hint above.

Loading history...
73 1
            ->orderBy('migration', SelectQuery::ASC);
74
        if ($status !== null) {
75 93
            $q->where($q->expr->eq('status', $q->bindValue($status)));
0 ignored issues
show
Unused Code introduced by
The call to eZ\Publish\Core\Persiste...se\SelectQuery::where() has too many arguments starting with $q->expr->eq('status', $q->bindValue($status)). ( Ignorable by Annotation )

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

75
            $q->/** @scrutinizer ignore-call */ 
76
                where($q->expr->eq('status', $q->bindValue($status)));

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress. Please note the @ignore annotation hint above.

Loading history...
76 1
        }
77
        if ($limit > 0 || $offset > 0) {
78
            if ($limit <= 0) {
79 1
                $limit = null;
80 1
            }
81
            if ($offset == 0) {
0 ignored issues
show
Bug Best Practice introduced by
It seems like you are loosely comparing $offset of type integer|null to 0; this is ambiguous as not only 0 == 0 is true, but null == 0 is true, too. Consider using a strict comparison ===.
Loading history...
82 1
                $offset = null;
83
            }
84 93
            $q->limit($limit, $offset);
85 93
        }
86 93
        $stmt = $q->prepare();
87
        $stmt->execute();
88 93
        $results = $stmt->fetchAll();
89 93
90 59
        $migrations = array();
91
        foreach ($results as $result) {
92
            $migrations[$result['migration']] = $this->arrayToMigration($result);
93 93
        }
94
95
        return new MigrationCollection($migrations);
96
    }
97
98
    /**
99
     * @param string $migrationName
100 62
     * @return APIMigration|null
101
     */
102 62
    public function loadMigration($migrationName)
103
    {
104
        $this->createTableIfNeeded();
105 62
106 62
        /** @var \eZ\Publish\Core\Persistence\Database\SelectQuery $q */
107 62
        $q = $this->dbHandler->createSelectQuery();
108 62
        $q->select($this->fieldList)
0 ignored issues
show
Unused Code introduced by
The call to eZ\Publish\Core\Persiste...e\SelectQuery::select() has too many arguments starting with $this->fieldList. ( Ignorable by Annotation )

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

108
        $q->/** @scrutinizer ignore-call */ 
109
            select($this->fieldList)

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress. Please note the @ignore annotation hint above.

Loading history...
109 62
            ->from($this->tableName)
0 ignored issues
show
Unused Code introduced by
The call to eZ\Publish\Core\Persiste...ase\SelectQuery::from() has too many arguments starting with $this->tableName. ( Ignorable by Annotation )

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

109
            ->/** @scrutinizer ignore-call */ from($this->tableName)

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress. Please note the @ignore annotation hint above.

Loading history...
110 62
            ->where($q->expr->eq('migration', $q->bindValue($migrationName)));
0 ignored issues
show
Unused Code introduced by
The call to eZ\Publish\Core\Persiste...se\SelectQuery::where() has too many arguments starting with $q->expr->eq('migration'...dValue($migrationName)). ( Ignorable by Annotation )

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

110
            ->/** @scrutinizer ignore-call */ where($q->expr->eq('migration', $q->bindValue($migrationName)));

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress. Please note the @ignore annotation hint above.

Loading history...
111 62
        $stmt = $q->prepare();
112
        $stmt->execute();
113 62
        $result = $stmt->fetch(\PDO::FETCH_ASSOC);
114 60
115
        if (is_array($result) && !empty($result)) {
116
            return $this->arrayToMigration($result);
117 60
        }
118
119
        return null;
120
    }
121
122
    /**
123
     * Creates and stores a new migration (leaving it in TODO status)
124
     * @param MigrationDefinition $migrationDefinition
125
     * @return APIMigration
126 61
     * @throws \Exception If the migration exists already (we rely on the PK for that)
127
     */
128 61
    public function addMigration(MigrationDefinition $migrationDefinition)
129
    {
130 61
        $this->createTableIfNeeded();
131
132 61
        $conn = $this->getConnection();
133 61
134 61
        $migration = new APIMigration(
135 61
            $migrationDefinition->name,
136 61
            md5($migrationDefinition->rawDefinition),
137 61
            $migrationDefinition->path,
138
            null,
139
            APIMigration::STATUS_TODO
140 61
        );
141
        try {
142
            $conn->insert($this->tableName, $this->migrationToArray($migration));
143
        } catch (UniqueConstraintViolationException $e) {
144
            throw new \Exception("Migration '{$migrationDefinition->name}' already exists");
145 61
        }
146
147
        return $migration;
148
    }
149
150
    /**
151
     * Starts a migration, given its definition: stores its status in the db, returns the Migration object
152
     *
153
     * @param MigrationDefinition $migrationDefinition
154
     * @param bool $force when true, starts migrations even if they already exist in DONE, SKIPPED status
155
     * @return APIMigration
156
     * @throws \Exception if migration was already executing or already done
157 49
     * @todo add a parameter to allow re-execution of already-done migrations
158
     */
159 49
    public function startMigration(MigrationDefinition $migrationDefinition, $force = false)
160
    {
161
        return $this->createMigration($migrationDefinition, APIMigration::STATUS_STARTED, 'started', $force);
162
    }
163
164
    /**
165
     * Stops a migration by storing it in the db. Migration status can not be 'started'
166
     *
167
     * NB: if this call happens within another DB transaction which has already been flagged for rollback, the result
168
     * will be that a RuntimeException is thrown, as Doctrine does not allow to call commit() after rollback().
169
     * One way to fix the problem would be not to use a transaction and select-for-update here, but since that is the
170
     * best way to insure atomic updates, I am loath to remove it.
171
     * A known workaround is to call the Doctrine Connection method setNestTransactionsWithSavepoints(true); this can
172
     * be achieved as simply as setting the parameter 'use_savepoints' in the doctrine connection configuration.
173
     *
174
     * @param APIMigration $migration
175
     * @param bool $force When true, the migration will be updated even if it was not in 'started' status
176 49
     * @throws \Exception If the migration was not started (unless $force=true)
177
     */
178 49
    public function endMigration(APIMigration $migration, $force = false)
179
    {
180
        if ($migration->status == APIMigration::STATUS_STARTED) {
181
            throw new \Exception($this->getEntityName($migration)." '{$migration->name}' can not be ended as its status is 'started'...");
182 49
        }
183
184
        $this->createTableIfNeeded();
185
186
        // select for update
187
188 49
        // annoyingly enough, neither Doctrine nor EZP provide built in support for 'FOR UPDATE' in their query builders...
189
        // at least the doctrine one allows us to still use parameter binding when we add our sql particle
190 49
        $conn = $this->getConnection();
191 49
192 49
        $qb = $conn->createQueryBuilder();
193 49
        $qb->select('*')
194 49
            ->from($this->tableName, 'm')
195
            ->where('migration = ?');
196 49
        $sql = $qb->getSQL() . ' FOR UPDATE';
197
198 49
        $conn->beginTransaction();
199 49
200
        $stmt = $conn->executeQuery($sql, array($migration->name));
201
        $existingMigrationData = $stmt->fetch(\PDO::FETCH_ASSOC);
202
203 49
        // fail if it was not executing
204
205
        if (!is_array($existingMigrationData)) {
206
            // commit to release the lock
207
            $conn->commit();
208
            throw new \Exception($this->getEntityName($migration)." '{$migration->name}' can not be ended as it is not found");
209 49
        }
210
211
        if (($existingMigrationData['status'] != APIMigration::STATUS_STARTED) && !$force) {
212
            // commit to release the lock
213
            $conn->commit();
214
            throw new \Exception($this->getEntityName($migration)." '{$migration->name}' can not be ended as it is not executing");
215 49
        }
216 49
217
        $conn->update(
218 49
            $this->tableName,
219
            array(
220 49
                'status' => $migration->status,
221 49
                /// @todo use mb_substr (if all dbs we support count col length not in bytes but in chars...)
222
                'execution_error' => substr($migration->executionError, 0, 4000),
223 49
                'execution_date' => $migration->executionDate
224
            ),
225
            array('migration' => $migration->name)
226 49
        );
227 49
228
        $conn->commit();
229
    }
230
231
    /**
232
     * Removes a Migration from the table - regardless of its state!
233
     *
234 58
     * @param APIMigration $migration
235
     */
236 58
    public function deleteMigration(APIMigration $migration)
237 58
    {
238 58
        $this->createTableIfNeeded();
239 58
        $conn = $this->getConnection();
240
        $conn->delete($this->tableName, array('migration' => $migration->name));
241
    }
242
243
    /**
244
     * Skips a migration by storing it in the db. Migration status can not be 'started'
245
     *
246
     * @param MigrationDefinition $migrationDefinition
247
     * @return APIMigration
248
     * @throws \Exception If the migration was already executed or executing
249
     */
250
    public function skipMigration(MigrationDefinition $migrationDefinition)
251
    {
252
        return $this->createMigration($migrationDefinition, APIMigration::STATUS_SKIPPED, 'skipped');
253
    }
254
255
    /**
256
     * @param MigrationDefinition $migrationDefinition
257
     * @param int $status
258
     * @param string $action
259
     * @param bool $force
260
     * @return APIMigration
261 49
     * @throws \Exception
262
     */
263 49
    protected function createMigration(MigrationDefinition $migrationDefinition, $status, $action, $force = false)
264
    {
265
        $this->createTableIfNeeded();
266
267
        // select for update
268
269 49
        // annoyingly enough, neither Doctrine nor EZP provide built in support for 'FOR UPDATE' in their query builders...
270
        // at least the doctrine one allows us to still use parameter binding when we add our sql particle
271 49
        $conn = $this->getConnection();
272 49
273 49
        $qb = $conn->createQueryBuilder();
274 49
        $qb->select('*')
275 49
            ->from($this->tableName, 'm')
276
            ->where('migration = ?');
277 49
        $sql = $qb->getSQL() . ' FOR UPDATE';
278
279 49
        $conn->beginTransaction();
280 49
281
        $stmt = $conn->executeQuery($sql, array($migrationDefinition->name));
282 49
        $existingMigrationData = $stmt->fetch(\PDO::FETCH_ASSOC);
283
284
        if (is_array($existingMigrationData)) {
285
            // migration exists
286 47
287
            // fail if it was already executing
288
            if ($existingMigrationData['status'] == APIMigration::STATUS_STARTED) {
289
                // commit to release the lock
290
                $conn->commit();
291
                throw new \Exception("Migration '{$migrationDefinition->name}' can not be $action as it is already executing");
292 47
            }
293 45
            // fail if it was already already done, unless in 'force' mode
294
            if (!$force) {
295
                if ($existingMigrationData['status'] == APIMigration::STATUS_DONE) {
296
                    // commit to release the lock
297
                    $conn->commit();
298 45
                    throw new \Exception("Migration '{$migrationDefinition->name}' can not be $action as it was already executed");
299
                }
300
                if ($existingMigrationData['status'] == APIMigration::STATUS_SKIPPED) {
301
                    // commit to release the lock
302
                    $conn->commit();
303
                    throw new \Exception("Migration '{$migrationDefinition->name}' can not be $action as it was already skipped");
304
                }
305
            }
306 47
307 47
            // do not set migration start date if we are skipping it
308 47
            $migration = new APIMigration(
309 47
                $migrationDefinition->name,
310 47
                md5($migrationDefinition->rawDefinition),
311 47
                $migrationDefinition->path,
312
                ($status == APIMigration::STATUS_SKIPPED ? null : time()),
313 47
                $status
314 47
            );
315
            $conn->update(
316 47
                $this->tableName,
317 47
                array(
318
                    'execution_date' => $migration->executionDate,
319
                    'status' => $status,
320 47
                    'execution_error' => null
321
                ),
322 47
                array('migration' => $migrationDefinition->name)
323
            );
324
            $conn->commit();
325
326
        } else {
327
            // migration did not exist. Create it!
328 2
329
            // commit immediately, to release the lock and avoid deadlocks
330 2
            $conn->commit();
331 2
332 2
            $migration = new APIMigration(
333 2
                $migrationDefinition->name,
334 2
                md5($migrationDefinition->rawDefinition),
335 2
                $migrationDefinition->path,
336
                ($status == APIMigration::STATUS_SKIPPED ? null : time()),
337 2
                $status
338
            );
339
            $conn->insert($this->tableName, $this->migrationToArray($migration));
340 49
        }
341
342
        return $migration;
343
    }
344
345
    public function resumeMigration(APIMigration $migration)
346
    {
347
        $this->createTableIfNeeded();
348
349
        // select for update
350
351
        // annoyingly enough, neither Doctrine nor EZP provide built in support for 'FOR UPDATE' in their query builders...
352
        // at least the doctrine one allows us to still use parameter binding when we add our sql particle
353
        $conn = $this->getConnection();
354
355
        $qb = $conn->createQueryBuilder();
356
        $qb->select('*')
357
            ->from($this->tableName, 'm')
358
            ->where('migration = ?');
359
        $sql = $qb->getSQL() . ' FOR UPDATE';
360
361
        $conn->beginTransaction();
362
363
        $stmt = $conn->executeQuery($sql, array($migration->name));
364
        $existingMigrationData = $stmt->fetch(\PDO::FETCH_ASSOC);
365
366
        if (!is_array($existingMigrationData)) {
367
            // commit immediately, to release the lock and avoid deadlocks
368
            $conn->commit();
369
            throw new \Exception($this->getEntityName($migration)." '{$migration->name}' can not be resumed as it is not found");
370
        }
371
372
        // migration exists
373
374
        // fail if it was not suspended
375
        if ($existingMigrationData['status'] != APIMigration::STATUS_SUSPENDED) {
376
            // commit to release the lock
377
            $conn->commit();
378
            throw new \Exception($this->getEntityName($migration)." '{$migration->name}' can not be resumed as it is not suspended");
379
        }
380
381
        $migration = new APIMigration(
382
            $migration->name,
383
            $migration->md5,
384
            $migration->path,
385
            time(),
386
            APIMigration::STATUS_STARTED
387
        );
388
389
        $conn->update(
390
            $this->tableName,
391
            array(
392
                'execution_date' => $migration->executionDate,
393
                'status' => APIMigration::STATUS_STARTED,
394
                'execution_error' => null
395
            ),
396
            array('migration' => $migration->name)
397
        );
398
        $conn->commit();
399
400
        return $migration;
401
    }
402
403
    /**
404
     * Removes all migration from storage (regardless of their status)
405
     */
406
    public function deleteMigrations()
407
    {
408
        $this->drop();
409 1
    }
410
411
    public function createTable()
412 1
    {
413 1
        /** @var \Doctrine\DBAL\Schema\AbstractSchemaManager $sm */
414
        $sm = $this->getConnection()->getSchemaManager();
415 1
        $dbPlatform = $sm->getDatabasePlatform();
416
417 1
        $schema = new Schema();
418 1
419 1
        $t = $schema->createTable($this->tableName);
420 1
        $t->addColumn('migration', 'string', array('length' => 255));
421 1
        $t->addColumn('path', 'string', array('length' => 4000));
422 1
        $t->addColumn('md5', 'string', array('length' => 32));
423 1
        $t->addColumn('execution_date', 'integer', array('notnull' => false));
424 1
        $t->addColumn('status', 'integer', array('default ' => APIMigration::STATUS_TODO));
425
        $t->addColumn('execution_error', 'string', array('length' => 4000, 'notnull' => false));
426
        $t->setPrimaryKey(array('migration'));
427
        // in case users want to look up migrations by their full path
428
        // NB: disabled for the moment, as it causes problems on some versions of mysql which limit index length to 767 bytes,
429
        // and 767 bytes can be either 255 chars or 191 chars depending on charset utf8 or utf8mb4...
430 1
        //$t->addIndex(array('path'));
431 1
432
        $this->injectTableCreationOptions($t);
433 1
434
        foreach ($schema->toSql($dbPlatform) as $sql) {
435 62
            try {
436
                $this->dbHandler->exec($sql);
437
            } catch(QueryException $e) {
438 62
                // work around limitations in both Mysql and Doctrine
439 62
                // @see https://github.com/kaliop-uk/ezmigrationbundle/issues/176
440 62
                if (strpos($e->getMessage(), '1071 Specified key was too long; max key length is 767 bytes') !== false &&
441 62
                    strpos($sql, 'PRIMARY KEY(migration)') !== false) {
442 62
                    $this->dbHandler->exec(str_replace('PRIMARY KEY(migration)', 'PRIMARY KEY(migration(191))', $sql));
443 62
                }
444
            }
445
        }
446
    }
447 61
448
    protected function migrationToArray(APIMigration $migration)
449 61
    {
450 61
        return array(
451 61
            'migration' => $migration->name,
452 61
            'md5' => $migration->md5,
453 61
            'path' => $migration->path,
454 61
            'execution_date' => $migration->executionDate,
455 61
            'status' => $migration->status,
456
            'execution_error' => $migration->executionError
457
        );
458
    }
459
460
    protected function arrayToMigration(array $data)
461
    {
462
        return new APIMigration(
463
            $data['migration'],
464
            $data['md5'],
465
            $data['path'],
466
            $data['execution_date'],
467
            $data['status'],
468
            $data['execution_error']
469
        );
470
    }
471
472
    protected function getEntityName($migration)
473
    {
474
        return end(explode('\\', get_class($migration)));
475
    }
476
}
477