Migrator::rollback()   A
last analyzed

Complexity

Conditions 5
Paths 5

Size

Total Lines 31
Code Lines 16

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 5
eloc 16
c 1
b 0
f 0
nc 5
nop 1
dl 0
loc 31
rs 9.4222
1
<?php
2
3
declare(strict_types=1);
4
5
/*
6
 * This file is part of Biurad opensource projects.
7
 *
8
 * PHP version 7.2 and above required
9
 *
10
 * @author    Divine Niiquaye Ibok <[email protected]>
11
 * @copyright 2019 Biurad Group (https://biurad.com/)
12
 * @license   https://opensource.org/licenses/BSD-3-Clause License
13
 *
14
 * For the full copyright and license information, please view the LICENSE
15
 * file that was distributed with this source code.
16
 */
17
18
namespace Biurad\Cycle;
19
20
use DateTime;
21
use DateTimeImmutable;
22
use DateTimeInterface;
23
use Spiral\Database\Database as SpiralDatabase;
24
use Spiral\Database\Table;
25
use Spiral\Migrations\Capsule;
26
use Spiral\Migrations\CapsuleInterface;
27
use Spiral\Migrations\Config\MigrationConfig;
28
use Spiral\Migrations\Exception\MigrationException;
29
use Spiral\Migrations\MigrationInterface;
30
use Spiral\Migrations\RepositoryInterface;
31
use Spiral\Migrations\State;
32
use Throwable;
33
34
final class Migrator
35
{
36
    private const DB_DATE_FORMAT = 'Y-m-d H:i:s';
37
38
    private const MIGRATION_TABLE_FIELDS_LIST = [
39
        'id',
40
        'migration',
41
        'time_executed',
42
        'created_at',
43
    ];
44
45
    /** @var MigrationConfig */
46
    private $config;
47
48
    /** @var Database */
49
    private $dbal;
50
51
    /** @var RepositoryInterface */
52
    private $repository;
53
54
    /**
55
     * @param MigrationConfig     $config
56
     * @param Database            $dbal
57
     * @param RepositoryInterface $repository
58
     */
59
    public function __construct(
60
        MigrationConfig $config,
61
        Database $dbal,
62
        RepositoryInterface $repository
63
    ) {
64
        $this->config     = $config;
65
        $this->repository = $repository;
66
        $this->dbal       = $dbal;
67
    }
68
69
    /**
70
     * @return MigrationConfig
71
     */
72
    public function getConfig(): MigrationConfig
73
    {
74
        return $this->config;
75
    }
76
77
    /**
78
     * @return RepositoryInterface
79
     */
80
    public function getRepository(): RepositoryInterface
81
    {
82
        return $this->repository;
83
    }
84
85
    /**
86
     * Check if all related databases are configures with migrations.
87
     *
88
     * @return bool
89
     */
90
    public function isConfigured(): bool
91
    {
92
        foreach ($this->dbal->getDatabases() as $db) {
93
            if (!$db->hasTable($this->config->getTable()) || !$this->checkMigrationTableStructure($db)) {
94
                return false;
95
            }
96
        }
97
98
        return !$this->isRestoreMigrationDataRequired();
99
    }
100
101
    /**
102
     * Configure all related databases with migration table.
103
     */
104
    public function configure(): void
105
    {
106
        if ($this->isConfigured()) {
107
            return;
108
        }
109
110
        foreach ($this->dbal->getDatabases() as $db) {
111
            $schema = $db->table($this->config->getTable())->getSchema();
112
113
            // Schema update will automatically sync all needed data
114
            $schema->primary('id');
115
            $schema->string('migration', 191)->nullable(false);
116
            $schema->datetime('time_executed')->datetime();
117
            $schema->datetime('created_at')->datetime();
118
            $schema->index(['migration', 'created_at'])
119
                ->unique(true);
120
121
            if ($schema->hasIndex(['migration'])) {
122
                $schema->dropIndex(['migration']);
123
            }
124
125
            $schema->save();
126
        }
127
128
        if ($this->isRestoreMigrationDataRequired()) {
129
            $this->restoreMigrationData();
130
        }
131
    }
132
133
    /**
134
     * Get every available migration with valid meta information.
135
     *
136
     * @return MigrationInterface[]
137
     */
138
    public function getMigrations(): array
139
    {
140
        $result = [];
141
142
        foreach ($this->repository->getMigrations() as $migration) {
143
            //Populating migration state and execution time (if any)
144
            $result[] = $migration->withState($this->resolveState($migration));
145
        }
146
147
        return $result;
148
    }
149
150
    /**
151
     * Execute one migration and return it's instance.
152
     *
153
     * @param CapsuleInterface $capsule
154
     *
155
     * @throws MigrationException
156
     *
157
     * @return null|MigrationInterface
158
     */
159
    public function run(CapsuleInterface $capsule = null): ?MigrationInterface
160
    {
161
        if (!$this->isConfigured()) {
162
            throw new MigrationException('Unable to run migration, Migrator not configured');
163
        }
164
165
        foreach ($this->getMigrations() as $migration) {
166
            if ($migration->getState()->getStatus() !== State::STATUS_PENDING) {
167
                continue;
168
            }
169
170
            try {
171
                $capsule = $capsule ?? new Capsule($this->dbal->database($migration->getDatabase()));
172
                $capsule->getDatabase($migration->getDatabase())->transaction(
0 ignored issues
show
Bug introduced by
The method transaction() does not exist on Spiral\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

172
                $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 Spiral\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

172
                $capsule->/** @scrutinizer ignore-call */ 
173
                          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 Spiral\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

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