Passed
Push — master ( 565c26...461f06 )
by Aleksei
02:34
created

Migrator::createMigrationTable()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 2
c 0
b 0
f 0
dl 0
loc 4
rs 10
cc 1
nc 1
nop 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
use Cycle\Migrations\Migration\State;
0 ignored issues
show
Bug introduced by
This use statement conflicts with another class in this namespace, Cycle\Migrations\State. Consider defining an alias.

Let?s assume that you have a directory layout like this:

.
|-- OtherDir
|   |-- Bar.php
|   `-- Foo.php
`-- SomeDir
    `-- Foo.php

and let?s assume the following content of Bar.php:

// Bar.php
namespace OtherDir;

use SomeDir\Foo; // This now conflicts the class OtherDir\Foo

If both files OtherDir/Foo.php and SomeDir/Foo.php are loaded in the same runtime, you will see a PHP error such as the following:

PHP Fatal error:  Cannot use SomeDir\Foo as Foo because the name is already in use in OtherDir/Foo.php

However, as OtherDir/Foo.php does not necessarily have to be loaded and the error is only triggered if it is loaded before OtherDir/Bar.php, this problem might go unnoticed for a while. In order to prevent this error from surfacing, you must import the namespace with a different alias:

// Bar.php
namespace OtherDir;

use SomeDir\Foo as SomeDirFoo; // There is no conflict anymore.
Loading history...
20
use Cycle\Migrations\Migration\Status;
21
use Cycle\Migrations\Migrator\MigrationsTable;
22
23
final class Migrator implements MigratorInterface
24
{
25
    private const DB_DATE_FORMAT = 'Y-m-d H:i:s';
26
27
    /** @var MigrationConfig */
28
    private $config;
29
30
    /** @var DatabaseManager */
31
    private $dbal;
32
33
    /** @var RepositoryInterface */
34
    private $repository;
35
36
    /**
37
     * @param MigrationConfig     $config
38
     * @param DatabaseManager     $dbal
39
     * @param RepositoryInterface $repository
40
     */
41
    public function __construct(
42
        MigrationConfig $config,
43
        DatabaseManager $dbal,
44
        RepositoryInterface $repository
45
    ) {
46
        $this->config = $config;
47
        $this->repository = $repository;
48
        $this->dbal = $dbal;
49
    }
50
51
    /**
52
     * @return MigrationConfig
53
     */
54
    public function getConfig(): MigrationConfig
55
    {
56
        return $this->config;
57
    }
58
59
    /**
60
     * @return RepositoryInterface
61
     */
62
    public function getRepository(): RepositoryInterface
63
    {
64
        return $this->repository;
65
    }
66
67
    /**
68
     * @return bool
69
     */
70
    public function isConfigured(): bool
71
    {
72
        $databases = $this->getDatabases();
73
74
        foreach ($databases as $db) {
75
            if (!$this->checkMigrationTableStructure($db)) {
76
                return false;
77
            }
78
        }
79
80
        return !$this->isRestoreMigrationDataRequired($databases);
81
    }
82
83
    /**
84
     * {@inheritDoc}
85
     */
86
    public function configure(): void
87
    {
88
        if ($this->isConfigured()) {
89
            return;
90
        }
91
92
        $databases = $this->getDatabases();
93
94
        foreach ($databases as $database) {
95
            $this->createMigrationTable($database);
96
        }
97
98
        if ($this->isRestoreMigrationDataRequired($databases)) {
99
            $this->restoreMigrationData();
100
        }
101
    }
102
103
    /**
104
     * Get all databases for which there are migrations.
105
     *
106
     * @return array<Database>
107
     */
108
    private function getDatabases(): array
109
    {
110
        $result = [];
111
112
        foreach ($this->repository->getMigrations() as $migration) {
113
            $database = $this->dbal->database($migration->getDatabase());
114
115
            if (! isset($result[$database->getName()])) {
116
                $result[$database->getName()] = $database;
117
            }
118
        }
119
120
        return $result;
121
    }
122
123
    /**
124
     * Create migration table inside given database
125
     *
126
     * @param Database $database
127
     */
128
    private function createMigrationTable(Database $database): void
129
    {
130
        $table = new MigrationsTable($database, $this->config->getTable());
131
        $table->actualize();
132
    }
133
134
    /**
135
     * Get every available migration with valid meta information.
136
     *
137
     * @return MigrationInterface[]
138
     * @throws \Exception
139
     */
140
    public function getMigrations(): array
141
    {
142
        $result = [];
143
144
        foreach ($this->repository->getMigrations() as $migration) {
145
            // Populating migration state and execution time (if any)
146
            $result[] = $migration->withState($this->resolveState($migration));
147
        }
148
149
        return $result;
150
    }
151
152
    /**
153
     * {@inheritDoc}
154
     */
155
    public function run(CapsuleInterface $capsule = null): ?MigrationInterface
156
    {
157
        if (!$this->isConfigured()) {
158
            $this->configure();
159
        }
160
161
        foreach ($this->getMigrations() as $migration) {
162
            $state = $migration->getState();
163
164
            if ($state->getStatus() !== Status::STATUS_PENDING) {
165
                continue;
166
            }
167
168
            try {
169
                $capsule = $capsule ?? new Capsule($this->dbal->database($migration->getDatabase()));
170
                $capsule->getDatabase()->transaction(
171
                    static function () use ($migration, $capsule): void {
172
                        $migration->withCapsule($capsule)->up();
173
                    }
174
                );
175
176
                $this->migrationTable($migration->getDatabase())->insertOne(
177
                    [
178
                        'migration' => $state->getName(),
179
                        'time_executed' => new \DateTime('now'),
180
                        'created_at' => $this->getMigrationCreatedAtForDb($migration),
181
                    ]
182
                );
183
184
                return $migration->withState($this->resolveState($migration));
185
            } catch (\Throwable $exception) {
186
                $state = $migration->getState();
187
                throw new MigrationException(
188
                    \sprintf(
189
                        'Error in the migration (%s) occurred: %s',
190
                        \sprintf(
191
                            '%s (%s)',
192
                            $state->getName(),
193
                            $state->getTimeCreated()->format(self::DB_DATE_FORMAT)
194
                        ),
195
                        $exception->getMessage()
196
                    ),
197
                    (int)$exception->getCode(),
198
                    $exception
199
                );
200
            }
201
        }
202
203
        return null;
204
    }
205
206
    /**
207
     * @param CapsuleInterface|null $capsule
208
     * @return MigrationInterface|null
209
     * @throws \Throwable
210
     */
211
    public function rollback(CapsuleInterface $capsule = null): ?MigrationInterface
212
    {
213
        if (!$this->isConfigured()) {
214
            $this->configure();
215
        }
216
217
        /** @var MigrationInterface $migration */
218
        foreach (\array_reverse($this->getMigrations()) as $migration) {
219
            if ($migration->getState()->getStatus() !== Status::STATUS_EXECUTED) {
220
                continue;
221
            }
222
223
            $capsule = $capsule ?? new Capsule($this->dbal->database($migration->getDatabase()));
224
            $capsule->getDatabase()->transaction(
225
                static function () use ($migration, $capsule): void {
226
                    $migration->withCapsule($capsule)->down();
227
                }
228
            );
229
230
            $migrationData = $this->fetchMigrationData($migration);
231
232
            if (!empty($migrationData)) {
233
                $this->migrationTable($migration->getDatabase())
234
                    ->delete(['id' => $migrationData['id']])
235
                    ->run();
236
            }
237
238
            return $migration->withState($this->resolveState($migration));
239
        }
240
241
        return null;
242
    }
243
244
    /**
245
     * Clarify migration state with valid status and execution time
246
     *
247
     * @param MigrationInterface $migration
248
     * @return State
249
     * @throws \Exception
250
     */
251
    private function resolveState(MigrationInterface $migration): State
252
    {
253
        $db = $this->dbal->database($migration->getDatabase());
254
255
        $data = $this->fetchMigrationData($migration);
256
257
        if (empty($data['time_executed'])) {
258
            return $migration->getState()->withStatus(Status::STATUS_PENDING);
259
        }
260
261
        return $migration->getState()->withStatus(
262
            Status::STATUS_EXECUTED,
263
            new \DateTimeImmutable($data['time_executed'], $db->getDriver()->getTimezone())
264
        );
265
    }
266
267
    /**
268
     * Migration table, all migration information will be stored in it.
269
     *
270
     * @param string|null $database
271
     * @return Table
272
     */
273
    private function migrationTable(string $database = null): Table
274
    {
275
        return $this->dbal->database($database)->table($this->config->getTable());
276
    }
277
278
    /**
279
     * @param Database $db
280
     * @return bool
281
     */
282
    private function checkMigrationTableStructure(Database $db): bool
283
    {
284
        $table = new MigrationsTable($db, $this->config->getTable());
285
286
        return $table->isPresent();
287
    }
288
289
    /**
290
     * Fetch migration information from database
291
     *
292
     * @param MigrationInterface $migration
293
     *
294
     * @return array|null
295
     */
296
    private function fetchMigrationData(MigrationInterface $migration): ?array
297
    {
298
        $migrationData = $this->migrationTable($migration->getDatabase())
299
            ->select('id', 'time_executed', 'created_at')
300
            ->where(
301
                [
302
                    'migration' => $migration->getState()->getName(),
303
                    'created_at' => $this->getMigrationCreatedAtForDb($migration)->format(self::DB_DATE_FORMAT),
304
                ]
305
            )
306
            ->run()
307
            ->fetch();
308
309
        return is_array($migrationData) ? $migrationData : [];
310
    }
311
312
    /**
313
     * This method updates the state of the empty (null) "created_at" fields for
314
     * each entry in the migration table within the
315
     * issue {@link https://github.com/spiral/migrations/issues/13}.
316
     *
317
     * TODO It is worth noting that this method works in an extremely suboptimal
318
     *      way and requires optimizations.
319
     *
320
     * @return void
321
     */
322
    private function restoreMigrationData(): void
323
    {
324
        foreach ($this->repository->getMigrations() as $migration) {
325
            $migrationData = $this->migrationTable($migration->getDatabase())
326
                ->select('id')
327
                ->where(
328
                    [
329
                        'migration' => $migration->getState()->getName(),
330
                        'created_at' => null,
331
                    ]
332
                )
333
                ->run()
334
                ->fetch();
335
336
            if (!empty($migrationData)) {
337
                $this->migrationTable($migration->getDatabase())
338
                    ->update(
339
                        ['created_at' => $this->getMigrationCreatedAtForDb($migration)],
340
                        ['id' => $migrationData['id']]
341
                    )
342
                    ->run();
343
            }
344
        }
345
    }
346
347
    /**
348
     * Check if some data modification required.
349
     *
350
     * This method checks for empty (null) "created_at" fields created within
351
     * the issue {@link https://github.com/spiral/migrations/issues/13}.
352
     *
353
     * @param iterable<Database> $databases
354
     * @return bool
355
     */
356
    private function isRestoreMigrationDataRequired(iterable $databases): bool
357
    {
358
        foreach ($databases as $db) {
359
            $table = $db->table($this->config->getTable());
360
361
            if (
362
                $table->select('id')
363
                    ->where(['created_at' => null])
364
                    ->count() > 0
365
            ) {
366
                return true;
367
            }
368
        }
369
370
        return false;
371
    }
372
373
    /**
374
     * Creates a new date object based on the database timezone and the
375
     * migration creation date.
376
     *
377
     * @param MigrationInterface $migration
378
     * @return \DateTimeInterface
379
     */
380
    private function getMigrationCreatedAtForDb(MigrationInterface $migration): \DateTimeInterface
381
    {
382
        $db = $this->dbal->database($migration->getDatabase());
383
384
        $createdAt = $migration->getState()
385
            ->getTimeCreated()
386
            ->format(self::DB_DATE_FORMAT)
387
        ;
388
389
        $timezone = $db->getDriver()
390
            ->getTimezone()
391
        ;
392
393
        return \DateTimeImmutable::createFromFormat(self::DB_DATE_FORMAT, $createdAt, $timezone);
394
    }
395
}
396