Passed
Push — master ( 2b8f81...145d48 )
by Gaetano
10:22 queued 05:19
created

Migration::startMigration()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

Changes 0
Metric Value
cc 1
eloc 1
nc 1
nop 2
dl 0
loc 3
ccs 0
cts 3
cp 0
crap 2
rs 10
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
     * @throws \Exception
32
     */
33
    public function __construct(DatabaseHandler $dbHandler, $tableNameParameter = 'kaliop_migrations', ConfigResolverInterface $configResolver = null, $tableCreationOptions = array())
34
    {
35
        parent::__construct($dbHandler, $tableNameParameter, $configResolver, $tableCreationOptions);
36
    }
37
38
    /**
39
     * @param int $limit
40
     * @param int $offset
41
     * @return MigrationCollection
42
     */
43
    public function loadMigrations($limit = null, $offset = null)
44
    {
45
        return $this->loadMigrationsInner(null, null, $limit, $offset);
46
    }
47
48
    /**
49
     * @param int $status
50
     * @param int $limit
51
     * @param int $offset
52
     * @return MigrationCollection
53
     */
54
    public function loadMigrationsByStatus($status, $limit = null, $offset = null)
55
    {
56
        return $this->loadMigrationsInner($status, null, $limit, $offset);
57
    }
58
59
    public function loadMigrationsByPaths($paths, $limit = null, $offset = null)
60
    {
61
        return $this->loadMigrationsInner(null, $paths, $limit, $offset);
62
    }
63
64
    /**
65
     * @param int $status
66
     * @param null|string[] $paths
67
     * @param int $limit
68
     * @param int $offset
69
     * @return MigrationCollection
70
     */
71
    protected function loadMigrationsInner($status = null, $paths = array(), $limit = null, $offset = null)
72
    {
73
        $this->createTableIfNeeded();
74
75
        /** @var \eZ\Publish\Core\Persistence\Database\SelectQuery $q */
76
        $q = $this->dbHandler->createSelectQuery();
77
        $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

77
        $q->/** @scrutinizer ignore-call */ 
78
            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...
78
            ->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

78
            ->/** @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...
79
            ->orderBy('migration', SelectQuery::ASC);
80
        if ($status !== null || (is_array($paths) && count($paths))) {
81
            $exps = [];
82
            if ($status !== null) {
83
                $exps[] = $q->expr->eq('status', $q->bindValue($status));
84
            }
85
            if (is_array($paths) && count($paths)) {
86
                $pexps = array();
87
                foreach($paths as $path) {
88
                    /// @todo use a proper db-aware escaping function
89
                    $pexps[] = $q->expr->like('path', "'" . str_replace(array('_', '%', "'"), array('\_', '\%', "''"), $path).'%' . "'");
90
                }
91
                $exps[] = $q->expr->lor($pexps);
0 ignored issues
show
Unused Code introduced by
The call to eZ\Publish\Core\Persiste...abase\Expression::lOr() has too many arguments starting with $pexps. ( Ignorable by Annotation )

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

91
                /** @scrutinizer ignore-call */ 
92
                $exps[] = $q->expr->lor($pexps);

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...
92
            }
93
            $q->where($q->expr->land($exps));
0 ignored issues
show
Unused Code introduced by
The call to eZ\Publish\Core\Persiste...base\Expression::lAnd() has too many arguments starting with $exps. ( Ignorable by Annotation )

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

93
            $q->where($q->expr->/** @scrutinizer ignore-call */ land($exps));

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...
Unused Code introduced by
The call to eZ\Publish\Core\Persiste...se\SelectQuery::where() has too many arguments starting with $q->expr->land($exps). ( Ignorable by Annotation )

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

93
            $q->/** @scrutinizer ignore-call */ 
94
                where($q->expr->land($exps));

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...
94
        }
95
        if ($limit > 0 || $offset > 0) {
96
            if ($limit <= 0) {
97
                $limit = null;
98
            }
99
            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...
100
                $offset = null;
101
            }
102
            $q->limit($limit, $offset);
103
        }
104
        $stmt = $q->prepare();
105
        $stmt->execute();
106
        $results = $stmt->fetchAll();
107
108
        $migrations = array();
109
        foreach ($results as $result) {
110
            $migrations[$result['migration']] = $this->arrayToMigration($result);
111
        }
112
113
        return new MigrationCollection($migrations);
114
    }
115
116
    /**
117
     * @param string $migrationName
118
     * @return APIMigration|null
119
     */
120
    public function loadMigration($migrationName)
121
    {
122
        $this->createTableIfNeeded();
123
124
        /** @var \eZ\Publish\Core\Persistence\Database\SelectQuery $q */
125
        $q = $this->dbHandler->createSelectQuery();
126
        $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

126
        $q->/** @scrutinizer ignore-call */ 
127
            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...
127
            ->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

127
            ->/** @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...
128
            ->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

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