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

Migration::addMigration()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 20
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 10
CRAP Score 2.0185

Importance

Changes 0
Metric Value
cc 2
eloc 13
nc 2
nop 1
dl 0
loc 20
ccs 10
cts 12
cp 0.8333
crap 2.0185
rs 9.8333
c 0
b 0
f 0
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