Completed
Push — master ( 5a501b...3982a6 )
by Gaetano
08:04
created

Migration::migrationToArray()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 11

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

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

In this case you can add the @ignore PhpDoc annotation to the duplicate definition and it will be ignored.

Loading history...
70
            ->from($this->tableName)
0 ignored issues
show
Unused Code introduced by
The call to SelectQuery::from() has too many arguments starting with $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.

In this case you can add the @ignore PhpDoc annotation to the duplicate definition and it will be ignored.

Loading history...
71
            ->orderBy('migration', SelectQuery::ASC);
72
        if ($status !== null) {
73
            $q->where($q->expr->eq('status', $q->bindValue($status)));
0 ignored issues
show
Bug introduced by
Accessing expr on the interface eZ\Publish\Core\Persistence\Database\SelectQuery suggest that you code against a concrete implementation. How about adding an instanceof check?

If you access a property on an interface, you most likely code against a concrete implementation of the interface.

Available Fixes

  1. Adding an additional type check:

    interface SomeInterface { }
    class SomeClass implements SomeInterface {
        public $a;
    }
    
    function someFunction(SomeInterface $object) {
        if ($object instanceof SomeClass) {
            $a = $object->a;
        }
    }
    
  2. Changing the type hint:

    interface SomeInterface { }
    class SomeClass implements SomeInterface {
        public $a;
    }
    
    function someFunction(SomeClass $object) {
        $a = $object->a;
    }
    
Loading history...
Unused Code introduced by
The call to SelectQuery::where() has too many arguments starting with $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.

In this case you can add the @ignore PhpDoc annotation to the duplicate definition and it will be ignored.

Loading history...
74
        }
75
        if ($limit > 0 || $offset > 0) {
76
            if ($limit <= 0) {
77
                $limit = null;
78
            }
79
            if ($offset == 0) {
0 ignored issues
show
Bug 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...
80
                $offset = null;
81
            }
82
            $q->limit($limit, $offset);
83
        }
84
        $stmt = $q->prepare();
85
        $stmt->execute();
86
        $results = $stmt->fetchAll();
87
88
        $migrations = array();
89
        foreach ($results as $result) {
90
            $migrations[$result['migration']] = $this->arrayToMigration($result);
91
        }
92
93
        return new MigrationCollection($migrations);
94
    }
95
96
    /**
97
     * @param string $migrationName
98
     * @return APIMigration|null
99
     */
100 View Code Duplication
    public function loadMigration($migrationName)
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
101
    {
102
        $this->createTableIfNeeded();
103
104
        /** @var \eZ\Publish\Core\Persistence\Database\SelectQuery $q */
105
        $q = $this->dbHandler->createSelectQuery();
106
        $q->select($this->fieldList)
0 ignored issues
show
Unused Code introduced by
The call to SelectQuery::select() has too many arguments starting with $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.

In this case you can add the @ignore PhpDoc annotation to the duplicate definition and it will be ignored.

Loading history...
107
            ->from($this->tableName)
0 ignored issues
show
Unused Code introduced by
The call to SelectQuery::from() has too many arguments starting with $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.

In this case you can add the @ignore PhpDoc annotation to the duplicate definition and it will be ignored.

Loading history...
108
            ->where($q->expr->eq('migration', $q->bindValue($migrationName)));
0 ignored issues
show
Bug introduced by
Accessing expr on the interface eZ\Publish\Core\Persistence\Database\SelectQuery suggest that you code against a concrete implementation. How about adding an instanceof check?

If you access a property on an interface, you most likely code against a concrete implementation of the interface.

Available Fixes

  1. Adding an additional type check:

    interface SomeInterface { }
    class SomeClass implements SomeInterface {
        public $a;
    }
    
    function someFunction(SomeInterface $object) {
        if ($object instanceof SomeClass) {
            $a = $object->a;
        }
    }
    
  2. Changing the type hint:

    interface SomeInterface { }
    class SomeClass implements SomeInterface {
        public $a;
    }
    
    function someFunction(SomeClass $object) {
        $a = $object->a;
    }
    
Loading history...
Unused Code introduced by
The call to SelectQuery::where() has too many arguments starting with $q->expr->eq('migration'...dValue($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.

In this case you can add the @ignore PhpDoc annotation to the duplicate definition and it will be ignored.

Loading history...
109
        $stmt = $q->prepare();
110
        $stmt->execute();
111
        $result = $stmt->fetch(\PDO::FETCH_ASSOC);
112
113
        if (is_array($result) && !empty($result)) {
114
            return $this->arrayToMigration($result);
115
        }
116
117
        return null;
118
    }
119
120
    /**
121
     * Creates and stores a new migration (leaving it in TODO status)
122
     * @param MigrationDefinition $migrationDefinition
123
     * @return APIMigration
124
     * @throws \Exception If the migration exists already (we rely on the PK for that)
125
     */
126
    public function addMigration(MigrationDefinition $migrationDefinition)
127
    {
128
        $this->createTableIfNeeded();
129
130
        $conn = $this->getConnection();
131
132
        $migration = new APIMigration(
133
            $migrationDefinition->name,
134
            md5($migrationDefinition->rawDefinition),
135
            $migrationDefinition->path,
136
            null,
137
            APIMigration::STATUS_TODO
138
        );
139
        try {
140
            $conn->insert($this->tableName, $this->migrationToArray($migration));
141
        } catch (UniqueConstraintViolationException $e) {
142
            throw new \Exception("Migration '{$migrationDefinition->name}' already exists");
143
        }
144
145
        return $migration;
146
    }
147
148
    /**
149
     * Starts a migration, given its definition: stores its status in the db, returns the Migration object
150
     *
151
     * @param MigrationDefinition $migrationDefinition
152
     * @return APIMigration
153
     * @throws \Exception if migration was already executing or already done
154
     * @todo add a parameter to allow re-execution of already-done migrations
155
     */
156
    public function startMigration(MigrationDefinition $migrationDefinition)
157
    {
158
        return $this->createMigration($migrationDefinition, APIMigration::STATUS_STARTED, 'started');
159
    }
160
161
    /**
162
     * Stops a migration by storing it in the db. Migration status can not be 'started'
163
     *
164
     * NB: if this call happens within another DB transaction which has already been flagged for rollback, the result
165
     * will be that a RuntimeException is thrown, as Doctrine does not allow to call commit() after rollback().
166
     * One way to fix the problem would be not to use a transaction and select-for-update here, but since that is the
167
     * best way to insure atomic updates, I am loath to remove it.
168
     * A known workaround is to call the Doctrine Connection method setNestTransactionsWithSavepoints(true); this can
169
     * be achieved as simply as setting the parameter 'use_savepoints' in the doctrine connection configuration.
170
     *
171
     * @param APIMigration $migration
172
     * @param bool $force When true, the migration will be updated even if it was not in 'started' status
173
     * @throws \Exception If the migration was not started (unless $force=true)
174
     */
175
    public function endMigration(APIMigration $migration, $force = false)
176
    {
177
        if ($migration->status == APIMigration::STATUS_STARTED) {
178
            throw new \Exception($this->getEntityName($migration)." '{$migration->name}' can not be ended as its status is 'started'...");
179
        }
180
181
        $this->createTableIfNeeded();
182
183
        // select for update
184
185
        // annoyingly enough, neither Doctrine nor EZP provide built in support for 'FOR UPDATE' in their query builders...
186
        // at least the doctrine one allows us to still use parameter binding when we add our sql particle
187
        $conn = $this->getConnection();
188
189
        $qb = $conn->createQueryBuilder();
190
        $qb->select('*')
191
            ->from($this->tableName, 'm')
192
            ->where('migration = ?');
193
        $sql = $qb->getSQL() . ' FOR UPDATE';
194
195
        $conn->beginTransaction();
196
197
        $stmt = $conn->executeQuery($sql, array($migration->name));
198
        $existingMigrationData = $stmt->fetch(\PDO::FETCH_ASSOC);
199
200
        // fail if it was not executing
201
202 View Code Duplication
        if (!is_array($existingMigrationData)) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
203
            // commit to release the lock
204
            $conn->commit();
205
            throw new \Exception($this->getEntityName($migration)." '{$migration->name}' can not be ended as it is not found");
206
        }
207
208 View Code Duplication
        if (($existingMigrationData['status'] != APIMigration::STATUS_STARTED) && !$force) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
209
            // commit to release the lock
210
            $conn->commit();
211
            throw new \Exception($this->getEntityName($migration)." '{$migration->name}' can not be ended as it is not executing");
212
        }
213
214
        $conn->update(
215
            $this->tableName,
216
            array(
217
                'status' => $migration->status,
218
                /// @todo use mb_substr (if all dbs we support count col length not in bytes but in chars...)
219
                'execution_error' => substr($migration->executionError, 0, 4000),
220
                'execution_date' => $migration->executionDate
221
            ),
222
            array('migration' => $migration->name)
223
        );
224
225
        $conn->commit();
226
    }
227
228
    /**
229
     * Removes a Migration from the table - regardless of its state!
230
     *
231
     * @param APIMigration $migration
232
     */
233
    public function deleteMigration(APIMigration $migration)
234
    {
235
        $this->createTableIfNeeded();
236
        $conn = $this->getConnection();
237
        $conn->delete($this->tableName, array('migration' => $migration->name));
238
    }
239
240
    /**
241
     * Skips a migration by storing it in the db. Migration status can not be 'started'
242
     *
243
     * @param MigrationDefinition $migrationDefinition
244
     * @return APIMigration
245
     * @throws \Exception If the migration was already executed or executing
246
     */
247
    public function skipMigration(MigrationDefinition $migrationDefinition)
248
    {
249
        return $this->createMigration($migrationDefinition, APIMigration::STATUS_SKIPPED, 'skipped');
250
    }
251
252
    /**
253
     * @param MigrationDefinition $migrationDefinition
254
     * @param int $status
255
     * @param string $action
256
     * @return APIMigration
257
     * @throws \Exception
258
     */
259
    protected function createMigration(MigrationDefinition $migrationDefinition, $status, $action)
260
    {
261
        $this->createTableIfNeeded();
262
263
        // select for update
264
265
        // annoyingly enough, neither Doctrine nor EZP provide built in support for 'FOR UPDATE' in their query builders...
266
        // at least the doctrine one allows us to still use parameter binding when we add our sql particle
267
        $conn = $this->getConnection();
268
269
        $qb = $conn->createQueryBuilder();
270
        $qb->select('*')
271
            ->from($this->tableName, 'm')
272
            ->where('migration = ?');
273
        $sql = $qb->getSQL() . ' FOR UPDATE';
274
275
        $conn->beginTransaction();
276
277
        $stmt = $conn->executeQuery($sql, array($migrationDefinition->name));
278
        $existingMigrationData = $stmt->fetch(\PDO::FETCH_ASSOC);
279
280
        if (is_array($existingMigrationData)) {
281
            // migration exists
282
283
            // fail if it was already executing or already done
284 View Code Duplication
            if ($existingMigrationData['status'] == APIMigration::STATUS_STARTED) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
285
                // commit to release the lock
286
                $conn->commit();
287
                throw new \Exception("Migration '{$migrationDefinition->name}' can not be $action as it is already executing");
288
            }
289 View Code Duplication
            if ($existingMigrationData['status'] == APIMigration::STATUS_DONE) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
290
                // commit to release the lock
291
                $conn->commit();
292
                throw new \Exception("Migration '{$migrationDefinition->name}' can not be $action as it was already executed");
293
            }
294 View Code Duplication
            if ($existingMigrationData['status'] == APIMigration::STATUS_SKIPPED) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
295
                // commit to release the lock
296
                $conn->commit();
297
                throw new \Exception("Migration '{$migrationDefinition->name}' can not be $action as it was already skipped");
298
            }
299
300
            // do not set migration start date if we are skipping it
301
            $migration = new APIMigration(
302
                $migrationDefinition->name,
303
                md5($migrationDefinition->rawDefinition),
304
                $migrationDefinition->path,
305
                ($status == APIMigration::STATUS_SKIPPED ? null : time()),
306
                $status
307
            );
308
            $conn->update(
309
                $this->tableName,
310
                array(
311
                    'execution_date' => $migration->executionDate,
312
                    'status' => $status,
313
                    'execution_error' => null
314
                ),
315
                array('migration' => $migrationDefinition->name)
316
            );
317
            $conn->commit();
318
319
        } else {
320
            // migration did not exist. Create it!
321
322
            // commit immediately, to release the lock and avoid deadlocks
323
            $conn->commit();
324
325
            $migration = new APIMigration(
326
                $migrationDefinition->name,
327
                md5($migrationDefinition->rawDefinition),
328
                $migrationDefinition->path,
329
                ($status == APIMigration::STATUS_SKIPPED ? null : time()),
330
                $status
331
            );
332
            $conn->insert($this->tableName, $this->migrationToArray($migration));
333
        }
334
335
        return $migration;
336
    }
337
338
    public function resumeMigration(APIMigration $migration)
339
    {
340
        $this->createTableIfNeeded();
341
342
        // select for update
343
344
        // annoyingly enough, neither Doctrine nor EZP provide built in support for 'FOR UPDATE' in their query builders...
345
        // at least the doctrine one allows us to still use parameter binding when we add our sql particle
346
        $conn = $this->getConnection();
347
348
        $qb = $conn->createQueryBuilder();
349
        $qb->select('*')
350
            ->from($this->tableName, 'm')
351
            ->where('migration = ?');
352
        $sql = $qb->getSQL() . ' FOR UPDATE';
353
354
        $conn->beginTransaction();
355
356
        $stmt = $conn->executeQuery($sql, array($migration->name));
357
        $existingMigrationData = $stmt->fetch(\PDO::FETCH_ASSOC);
358
359 View Code Duplication
        if (!is_array($existingMigrationData)) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
360
            // commit immediately, to release the lock and avoid deadlocks
361
            $conn->commit();
362
            throw new \Exception($this->getEntityName($migration)." '{$migration->name}' can not be resumed as it is not found");
363
        }
364
365
        // migration exists
366
367
        // fail if it was not suspended
368 View Code Duplication
        if ($existingMigrationData['status'] != APIMigration::STATUS_SUSPENDED) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
369
            // commit to release the lock
370
            $conn->commit();
371
            throw new \Exception($this->getEntityName($migration)." '{$migration->name}' can not be resumed as it is not suspended");
372
        }
373
374
        $migration = new APIMigration(
375
            $migration->name,
376
            $migration->md5,
377
            $migration->path,
378
            time(),
379
            APIMigration::STATUS_STARTED
380
        );
381
382
        $conn->update(
383
            $this->tableName,
384
            array(
385
                'execution_date' => $migration->executionDate,
386
                'status' => APIMigration::STATUS_STARTED,
387
                'execution_error' => null
388
            ),
389
            array('migration' => $migration->name)
390
        );
391
        $conn->commit();
392
393
        return $migration;
394
    }
395
396
    /**
397
     * Removes all migration from storage (regardless of their status)
398
     */
399
    public function deleteMigrations()
400
    {
401
        $this->drop();
402
    }
403
404
    public function createTable()
405
    {
406
        /** @var \Doctrine\DBAL\Schema\AbstractSchemaManager $sm */
407
        $sm = $this->getConnection()->getSchemaManager();
408
        $dbPlatform = $sm->getDatabasePlatform();
409
410
        $schema = new Schema();
411
412
        $t = $schema->createTable($this->tableName);
413
        $t->addColumn('migration', 'string', array('length' => 255));
414
        $t->addColumn('path', 'string', array('length' => 4000));
415
        $t->addColumn('md5', 'string', array('length' => 32));
416
        $t->addColumn('execution_date', 'integer', array('notnull' => false));
417
        $t->addColumn('status', 'integer', array('default ' => APIMigration::STATUS_TODO));
418
        $t->addColumn('execution_error', 'string', array('length' => 4000, 'notnull' => false));
419
        $t->setPrimaryKey(array('migration'));
420
        // in case users want to look up migrations by their full path
421
        // NB: disabled for the moment, as it causes problems on some versions of mysql which limit index length to 767 bytes,
422
        // and 767 bytes can be either 255 chars or 191 chars depending on charset utf8 or utf8mb4...
423
        //$t->addIndex(array('path'));
0 ignored issues
show
Unused Code Comprehensibility introduced by
90% 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...
424
425
        foreach ($schema->toSql($dbPlatform) as $sql) {
426
            $this->dbHandler->exec($sql);
427
        }
428
    }
429
430
    protected function migrationToArray(APIMigration $migration)
431
    {
432
        return array(
433
            'migration' => $migration->name,
434
            'md5' => $migration->md5,
435
            'path' => $migration->path,
436
            'execution_date' => $migration->executionDate,
437
            'status' => $migration->status,
438
            'execution_error' => $migration->executionError
439
        );
440
    }
441
442
    protected function arrayToMigration(array $data)
443
    {
444
        return new APIMigration(
445
            $data['migration'],
446
            $data['md5'],
447
            $data['path'],
448
            $data['execution_date'],
449
            $data['status'],
450
            $data['execution_error']
451
        );
452
    }
453
454
    protected function getEntityName($migration)
455
    {
456
        return end(explode('\\', get_class($migration)));
0 ignored issues
show
Bug introduced by
explode('\\', get_class($migration)) cannot be passed to end() as the parameter $array expects a reference.
Loading history...
457
    }
458
}
459