Passed
Push — master ( 10a192...087136 )
by Aleksei
07:38 queued 05:42
created

Migrator   A

Complexity

Total Complexity 39

Size/Duplication

Total Lines 344
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
wmc 39
eloc 129
dl 0
loc 344
c 0
b 0
f 0
rs 9.28

15 Methods

Rating   Name   Duplication   Size   Complexity  
A getRepository() 0 3 1
A checkMigrationTableStructure() 0 11 3
A fetchMigrationData() 0 14 2
A run() 0 46 5
A isConfigured() 0 9 4
A rollback() 0 31 5
A isRestoreMigrationDataRequired() 0 15 3
A migrationTable() 0 3 1
A restoreMigrationData() 0 21 3
A getMigrations() 0 9 2
A __construct() 0 8 1
A getConfig() 0 3 1
A resolveState() 0 13 2
A configure() 0 26 5
A getMigrationCreatedAtForDb() 0 8 1
1
<?php
2
3
/**
4
 * Spiral Framework.
5
 *
6
 * @license MIT
7
 * @author  Anton Titov (Wolfy-J)
8
 */
9
10
declare(strict_types=1);
11
12
namespace Cycle\Migrations;
13
14
use Cycle\Database\Database;
15
use Cycle\Database\DatabaseManager;
16
use Cycle\Database\Table;
17
use Cycle\Migrations\Config\MigrationConfig;
18
use Cycle\Migrations\Exception\MigrationException;
19
20
final class Migrator
21
{
22
    private const DB_DATE_FORMAT = 'Y-m-d H:i:s';
23
24
    private const MIGRATION_TABLE_FIELDS_LIST = [
25
        'id',
26
        'migration',
27
        'time_executed',
28
        'created_at',
29
    ];
30
31
    /** @var MigrationConfig */
32
    private $config;
33
34
    /** @var DatabaseManager */
35
    private $dbal;
36
37
    /** @var RepositoryInterface */
38
    private $repository;
39
40
    /**
41
     * @param MigrationConfig     $config
42
     * @param DatabaseManager     $dbal
43
     * @param RepositoryInterface $repository
44
     */
45
    public function __construct(
46
        MigrationConfig $config,
47
        DatabaseManager $dbal,
48
        RepositoryInterface $repository
49
    ) {
50
        $this->config = $config;
51
        $this->repository = $repository;
52
        $this->dbal = $dbal;
53
    }
54
55
    /**
56
     * @return MigrationConfig
57
     */
58
    public function getConfig(): MigrationConfig
59
    {
60
        return $this->config;
61
    }
62
63
    /**
64
     * @return RepositoryInterface
65
     */
66
    public function getRepository(): RepositoryInterface
67
    {
68
        return $this->repository;
69
    }
70
71
    /**
72
     * Check if all related databases are configures with migrations.
73
     *
74
     * @return bool
75
     */
76
    public function isConfigured(): bool
77
    {
78
        foreach ($this->dbal->getDatabases() as $db) {
79
            if (!$db->hasTable($this->config->getTable()) || !$this->checkMigrationTableStructure($db)) {
80
                return false;
81
            }
82
        }
83
84
        return !$this->isRestoreMigrationDataRequired();
85
    }
86
87
    /**
88
     * Configure all related databases with migration table.
89
     */
90
    public function configure(): void
91
    {
92
        if ($this->isConfigured()) {
93
            return;
94
        }
95
96
        foreach ($this->dbal->getDatabases() as $db) {
97
            $schema = $db->table($this->config->getTable())->getSchema();
98
99
            // Schema update will automatically sync all needed data
100
            $schema->primary('id');
101
            $schema->string('migration', 191)->nullable(false);
102
            $schema->datetime('time_executed')->datetime();
103
            $schema->datetime('created_at')->datetime();
104
            $schema->index(['migration', 'created_at'])
105
                ->unique(true);
106
107
            if ($schema->hasIndex(['migration'])) {
108
                $schema->dropIndex(['migration']);
109
            }
110
111
            $schema->save();
112
        }
113
114
        if ($this->isRestoreMigrationDataRequired()) {
115
            $this->restoreMigrationData();
116
        }
117
    }
118
119
    /**
120
     * Get every available migration with valid meta information.
121
     *
122
     * @return MigrationInterface[]
123
     */
124
    public function getMigrations(): array
125
    {
126
        $result = [];
127
        foreach ($this->repository->getMigrations() as $migration) {
128
            //Populating migration state and execution time (if any)
129
            $result[] = $migration->withState($this->resolveState($migration));
130
        }
131
132
        return $result;
133
    }
134
135
    /**
136
     * Execute one migration and return it's instance.
137
     *
138
     * @param CapsuleInterface $capsule
139
     *
140
     * @throws MigrationException
141
     *
142
     * @return MigrationInterface|null
143
     */
144
    public function run(CapsuleInterface $capsule = null): ?MigrationInterface
145
    {
146
        if (!$this->isConfigured()) {
147
            throw new MigrationException('Unable to run migration, Migrator not configured');
148
        }
149
150
        foreach ($this->getMigrations() as $migration) {
151
            if ($migration->getState()->getStatus() !== State::STATUS_PENDING) {
152
                continue;
153
            }
154
155
            try {
156
                $capsule = $capsule ?? new Capsule($this->dbal->database($migration->getDatabase()));
157
                $capsule->getDatabase($migration->getDatabase())->transaction(
0 ignored issues
show
Bug introduced by
The method transaction() does not exist on Cycle\Database\DatabaseManager. ( Ignorable by Annotation )

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

157
                $capsule->getDatabase($migration->getDatabase())->/** @scrutinizer ignore-call */ transaction(

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
Unused Code introduced by
The call to Cycle\Migrations\Capsule::getDatabase() has too many arguments starting with $migration->getDatabase(). ( Ignorable by Annotation )

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

157
                $capsule->/** @scrutinizer ignore-call */ 
158
                          getDatabase($migration->getDatabase())->transaction(

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 Cycle\Migrations\CapsuleInterface::getDatabase() has too many arguments starting with $migration->getDatabase(). ( Ignorable by Annotation )

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

157
                $capsule->/** @scrutinizer ignore-call */ 
158
                          getDatabase($migration->getDatabase())->transaction(

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...
158
                    static function () use ($migration, $capsule): void {
159
                        $migration->withCapsule($capsule)->up();
160
                    }
161
                );
162
163
                $this->migrationTable($migration->getDatabase())->insertOne(
164
                    [
165
                        'migration' => $migration->getState()->getName(),
166
                        'time_executed' => new \DateTime('now'),
167
                        'created_at' => $this->getMigrationCreatedAtForDb($migration),
168
                    ]
169
                );
170
171
                return $migration->withState($this->resolveState($migration));
172
            } catch (\Throwable $exception) {
173
                throw new MigrationException(
174
                    \sprintf(
175
                        'Error in the migration (%s) occurred: %s',
176
                        \sprintf(
177
                            '%s (%s)',
178
                            $migration->getState()->getName(),
179
                            $migration->getState()->getTimeCreated()->format(self::DB_DATE_FORMAT)
180
                        ),
181
                        $exception->getMessage()
182
                    ),
183
                    $exception->getCode(),
184
                    $exception
185
                );
186
            }
187
        }
188
189
        return null;
190
    }
191
192
    /**
193
     * Rollback last migration and return it's instance.
194
     *
195
     * @param CapsuleInterface $capsule
196
     *
197
     * @throws \Throwable
198
     *
199
     * @return MigrationInterface|null
200
     */
201
    public function rollback(CapsuleInterface $capsule = null): ?MigrationInterface
202
    {
203
        if (!$this->isConfigured()) {
204
            throw new MigrationException('Unable to run migration, Migrator not configured');
205
        }
206
207
        /** @var MigrationInterface $migration */
208
        foreach (array_reverse($this->getMigrations()) as $migration) {
209
            if ($migration->getState()->getStatus() !== State::STATUS_EXECUTED) {
210
                continue;
211
            }
212
213
            $capsule = $capsule ?? new Capsule($this->dbal->database($migration->getDatabase()));
214
            $capsule->getDatabase()->transaction(
215
                static function () use ($migration, $capsule): void {
216
                    $migration->withCapsule($capsule)->down();
217
                }
218
            );
219
220
            $migrationData = $this->fetchMigrationData($migration);
221
222
            if (!empty($migrationData)) {
223
                $this->migrationTable($migration->getDatabase())
224
                    ->delete(['id' => $migrationData['id']])
225
                    ->run();
226
            }
227
228
            return $migration->withState($this->resolveState($migration));
229
        }
230
231
        return null;
232
    }
233
234
    /**
235
     * Clarify migration state with valid status and execution time
236
     *
237
     * @param MigrationInterface $migration
238
     *
239
     * @return State
240
     */
241
    protected function resolveState(MigrationInterface $migration): State
242
    {
243
        $db = $this->dbal->database($migration->getDatabase());
244
245
        $data = $this->fetchMigrationData($migration);
246
247
        if (empty($data['time_executed'])) {
248
            return $migration->getState()->withStatus(State::STATUS_PENDING);
249
        }
250
251
        return $migration->getState()->withStatus(
252
            State::STATUS_EXECUTED,
253
            new \DateTimeImmutable($data['time_executed'], $db->getDriver()->getTimezone())
254
        );
255
    }
256
257
    /**
258
     * Migration table, all migration information will be stored in it.
259
     *
260
     * @param string|null $database
261
     *
262
     * @return Table
263
     */
264
    protected function migrationTable(string $database = null): Table
265
    {
266
        return $this->dbal->database($database)->table($this->config->getTable());
267
    }
268
269
    protected function checkMigrationTableStructure(Database $db): bool
270
    {
271
        $table = $db->table($this->config->getTable());
272
273
        foreach (self::MIGRATION_TABLE_FIELDS_LIST as $field) {
274
            if (!$table->hasColumn($field)) {
275
                return false;
276
            }
277
        }
278
279
        return ! (!$table->hasIndex(['migration', 'created_at']))
280
281
282
283
         ;
284
    }
285
286
    /**
287
     * Fetch migration information from database
288
     *
289
     * @param MigrationInterface $migration
290
     *
291
     * @return array|null
292
     */
293
    protected function fetchMigrationData(MigrationInterface $migration): ?array
294
    {
295
        $migrationData = $this->migrationTable($migration->getDatabase())
296
            ->select('id', 'time_executed', 'created_at')
297
            ->where(
298
                [
299
                    'migration' => $migration->getState()->getName(),
300
                    'created_at' => $this->getMigrationCreatedAtForDb($migration)->format(self::DB_DATE_FORMAT),
301
                ]
302
            )
303
            ->run()
304
            ->fetch();
305
306
        return is_array($migrationData) ? $migrationData : [];
307
    }
308
309
    protected function restoreMigrationData(): void
310
    {
311
        foreach ($this->repository->getMigrations() as $migration) {
312
            $migrationData = $this->migrationTable($migration->getDatabase())
313
                ->select('id')
314
                ->where(
315
                    [
316
                        'migration' => $migration->getState()->getName(),
317
                        'created_at' => null,
318
                    ]
319
                )
320
                ->run()
321
                ->fetch();
322
323
            if (!empty($migrationData)) {
324
                $this->migrationTable($migration->getDatabase())
325
                    ->update(
326
                        ['created_at' => $this->getMigrationCreatedAtForDb($migration)],
327
                        ['id' => $migrationData['id']]
328
                    )
329
                    ->run();
330
            }
331
        }
332
    }
333
334
    /**
335
     * Check if some data modification required
336
     *
337
     * @return bool
338
     */
339
    protected function isRestoreMigrationDataRequired(): bool
340
    {
341
        foreach ($this->dbal->getDatabases() as $db) {
342
            $table = $db->table($this->config->getTable());
343
344
            if (
345
                $table->select('id')
346
                    ->where(['created_at' => null])
347
                    ->count() > 0
348
            ) {
349
                return true;
350
            }
351
        }
352
353
        return false;
354
    }
355
356
    protected function getMigrationCreatedAtForDb(MigrationInterface $migration): \DateTimeInterface
357
    {
358
        $db = $this->dbal->database($migration->getDatabase());
359
360
        return \DateTimeImmutable::createFromFormat(
361
            self::DB_DATE_FORMAT,
362
            $migration->getState()->getTimeCreated()->format(self::DB_DATE_FORMAT),
363
            $db->getDriver()->getTimezone()
364
        );
365
    }
366
}
367