Migrator::getToNumber()   A
last analyzed

Complexity

Conditions 3
Paths 3

Size

Total Lines 12

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 6
CRAP Score 3

Importance

Changes 0
Metric Value
dl 0
loc 12
ccs 6
cts 6
cp 1
rs 9.8666
c 0
b 0
f 0
cc 3
nc 3
nop 1
crap 3
1
<?php declare(strict_types=1);
2
/**
3
 * Starlit Db.
4
 *
5
 * @copyright Copyright (c) 2019 Starweb AB
6
 * @license   BSD 3-Clause
7
 */
8
9
namespace Starlit\Db\Migration;
10
11
use Starlit\Db\Db;
12
13
/**
14
 * Class for handling migration between different database versions.
15
 *
16
 * @author Andreas Nilsson <http://github.com/jandreasn>
17
 */
18
class Migrator
19
{
20
    private const DIRECTION_UP = 'up';
21
22
    private const DIRECTION_DOWN = 'down';
23
24
    /**
25
     * @var string
26
     */
27
    protected $migrationsTableName = 'migrations';
28
29
    /**
30
     * @var string
31
     */
32
    protected $migrationsDirectory;
33
34
    /**
35
     * @var Db
36
     */
37
    protected $db;
38
39
    /**
40
     * @var callable
41
     */
42
    protected $infoCallback;
43
44
    /**
45
     * @var AbstractMigration[]
46
     */
47
    protected $migrations;
48
49
    /**
50
     * @var int[]
51
     */
52
    protected $migratedNumbers;
53
54
    /**
55
     * @var bool
56
     */
57
    private $hasMigrationsTable;
58
59 9
    public function __construct(string $migrationsDirectory, Db $db, callable $infoCallback = null)
60
    {
61 9
        $this->migrationsDirectory = $migrationsDirectory;
62 9
        $this->db = $db;
63 9
        $this->infoCallback = $infoCallback;
64 9
    }
65
66
    /**
67
     * @return \SplFileInfo[]
68
     */
69 7
    protected function findMigrationFiles(): array
70
    {
71 7
        $migrationFiles = [];
72 7
        $directoryIterator = new \FilesystemIterator($this->migrationsDirectory);
73 7
        foreach ($directoryIterator as $fileInfo) {
74 7
            if ($fileInfo->isFile() && $fileInfo->getExtension() === 'php') {
75 7
                $migrationFiles[] = $fileInfo;
76
            }
77
        }
78
79 7
        return $migrationFiles;
80
    }
81
82
    /**
83
     * @return AbstractMigration[]
84
     */
85 7
    protected function loadMigrations(): array
86
    {
87 7
        $migrations = [];
88 7
        foreach ($this->findMigrationFiles() as $file) {
89 7
            $migration = $this->instantiateMigration($file);
90 7
            $migrations[$migration->getNumber()] = $migration;
91
        }
92
93 7
        ksort($migrations);
94
95 7
        return $migrations;
96
    }
97
98 7
    private function instantiateMigration(\SplFileInfo $file): AbstractMigration
99
    {
100 7
        require_once $file->getPathname();
101
102 7
        $className = '\\' . $file->getBasename('.' . $file->getExtension());
103 7
        $migration = new $className($this->db);
104
105 7
        return $migration;
106
    }
107
108
    /**
109
     * @return AbstractMigration[]
110
     */
111 7
    public function getMigrations(): array
112
    {
113 7
        if (!isset($this->migrations)) {
114 7
            $this->migrations = $this->loadMigrations();
115
        }
116
117 7
        return $this->migrations;
118
    }
119
120 7
    public function getLatestNumber(): int
121
    {
122 7
        $numbers = array_keys($this->getMigrations());
123 7
        $latest = end($numbers);
124
125 7
        return ($latest !== false) ? $latest : 0;
126
127
    }
128
129 7
    private function hasMigrationsTable(): bool
130
    {
131 7
        if (!isset($this->hasMigrationsTable)) {
132 7
            $this->hasMigrationsTable =
133 7
                (bool) $this->db->fetchValue('SHOW TABLES LIKE ?', [$this->migrationsTableName]);
134
        }
135
136 7
        return $this->hasMigrationsTable;
137
    }
138
139 7
    protected function createMigrationsTable(): void
140
    {
141 7
        if (!$this->hasMigrationsTable()) {
142 7
            $this->db->exec('
143 7
                CREATE TABLE `' . $this->migrationsTableName . '` (
144
                    `migration_number` BIGINT NOT NULL,
145
                    `completed_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
146
                    PRIMARY KEY (`migration_number`)
147
                )
148
            ');
149 7
            $this->hasMigrationsTable = true;
150
        }
151 7
    }
152
153 7
    protected function getMigratedNumbers(): array
154
    {
155 7
        if (!isset($this->migratedNumbers)) {
156 7
            $this->createMigrationsTable();
157
158 7
            $this->migratedNumbers = [];
159
160 7
            $sql = 'SELECT * FROM `' . $this->migrationsTableName . '` ORDER BY migration_number';
161 7
            foreach ($this->db->fetchRows($sql) as $row) {
162 7
                $this->migratedNumbers[] = $row['migration_number'];
163
            }
164
        }
165
166 7
        return $this->migratedNumbers;
167
    }
168
169 7
    public function getCurrentNumber(): int
170
    {
171 7
        $migratedNumbers = $this->getMigratedNumbers();
172 7
        $current = end($migratedNumbers);
173
174 7
        return ($current !== false) ? $current : 0;
175
    }
176
177 4
    protected function getDirection(int $toNumber): string
178
    {
179 4
        return ($this->getCurrentNumber() > $toNumber) ? self::DIRECTION_DOWN : self::DIRECTION_UP;
180
    }
181
182 4
    public function getMigrationsTo(int $to = null): array
183
    {
184 4
        $toNumber = $this->getToNumber($to);
185 4
        $allMigrations = $this->getMigrations();
186 4
        $direction = $this->getDirection($toNumber);
187 4
        if ($direction === self::DIRECTION_DOWN) {
188 1
            $allMigrations = array_reverse($allMigrations, true);
189
        }
190
191 4
        $migrations = $this->getMigrationsToBeMigrated($allMigrations, $toNumber, $direction);
192
193 4
        return $migrations;
194
    }
195
196 4
    private function getMigrationsToBeMigrated(array $allMigrations, int $toNumber, string $direction): array
197
    {
198 4
        $migrations = [];
199 4
        foreach ($allMigrations as $migrationNumber => $migration) {
200 4
            if ($this->shouldMigrationBeMigrated($migration, $toNumber, $direction)) {
201 3
                $migrations[$migrationNumber] = $migration;
202
            }
203
        }
204
205 4
        return $migrations;
206
    }
207
208 4
    private function shouldMigrationBeMigrated(AbstractMigration $migration, int $toNumber, string $direction): bool
209
    {
210 4
        if ($this->shouldMigrateUp($migration, $toNumber, $direction)
211 4
            || $this->shouldMigrateDown($migration, $toNumber, $direction)
212
        ) {
213 3
            return true;
214
        }
215
216 4
        return false;
217
    }
218
219 4
    private function shouldMigrateUp(AbstractMigration $migration, int $toNumber, string $direction): bool
220
    {
221 4
        return $direction === self::DIRECTION_UP
222 4
            && $migration->getNumber() <= $toNumber
223 4
            && !in_array($migration->getNumber(), $this->getMigratedNumbers());
224
    }
225
226 4
    private function shouldMigrateDown(AbstractMigration $migration, int $toNumber, string $direction): bool
227
    {
228 4
        return $direction == self::DIRECTION_DOWN
229 4
            && $migration->getNumber() > $toNumber
230 4
            && in_array($migration->getNumber(), $this->getMigratedNumbers());
231
    }
232
233 2
    protected function addMigratedMigration(AbstractMigration $migration): void
234
    {
235 2
        $this->createMigrationsTable();
236
237 2
        $this->db->exec(
238 2
            'INSERT INTO `' . $this->migrationsTableName . '` SET migration_number = ?',
239 2
            [$migration->getNumber()]
240
        );
241
242 2
        $this->migratedNumbers[] = $migration->getNumber();
243 2
    }
244
245 1
    protected function deleteMigratedMigration(AbstractMigration $migration): void
246
    {
247 1
        $this->createMigrationsTable();
248
249 1
        $this->db->exec(
250 1
            'DELETE FROM `' . $this->migrationsTableName . '` WHERE migration_number = ?',
251 1
            [$migration->getNumber()]
252
        );
253
254 1
        if (($key = array_search($migration->getNumber(), $this->migratedNumbers)) !== false) {
255 1
            unset($this->migratedNumbers[$key]);
256
        }
257 1
    }
258
259 1
    public function emptyDb(): void
260
    {
261 1
        if (($rows = $this->db->fetchRows('SHOW TABLES', [], true))) {
262 1
            $this->db->exec('SET foreign_key_checks = 0');
263 1
            foreach ($rows as $row) {
264 1
                $this->db->exec('DROP TABLE `' . $row[0] . '`');
265
            }
266 1
            $this->db->exec('SET foreign_key_checks = 1');
267
        }
268 1
    }
269
270
    /**
271
     * @throws \InvalidArgumentException
272
     */
273 5
    protected function getToNumber(int $to = null): int
274
    {
275 5
        if ($to === null) {
276 3
            return $this->getLatestNumber();
277
        }
278
279 5
        if (!in_array($to, array_keys($this->getMigrations()))) {
280 1
             throw new \InvalidArgumentException('Invalid migration number');
281
        }
282
283 4
        return $to;
284
    }
285
286
    /**
287
     * @param int|null $to A migration number to migrate to (if not provided, latest migration number will be used)
288
     * @return bool Returns true if any action was performed
289
     * @throws \InvalidArgumentException
290
     * @throws \RuntimeException
291
     */
292 6
    public function migrate(int $to = null): bool
293
    {
294 6
        if ($this->getCurrentNumber() > $this->getLatestNumber()) {
295 1
            throw new \RuntimeException(sprintf(
296 1
                'The current migration number (%d) is higher than latest available (%d). Something is wrong!',
297 1
                $this->getCurrentNumber(),
298 1
                $this->getLatestNumber()
299
            ));
300
        }
301
302 5
        $toNumber = $this->getToNumber($to);
303 4
        $migrations = $this->getMigrationsTo($toNumber);
304
305
        // If there's no migration to be done, we are up to date.
306 4
        if (empty($migrations)) {
307 1
            $this->addInfo(sprintf(
308 1
                'No migrations available, things are up to date (migration %d)!',
309 1
                $this->getCurrentNumber()
310
            ));
311
312 1
            return false;
313
        }
314
315 3
        $this->addInfo(sprintf(
316 3
            'Running %d migrations from migration %d to %d...',
317 3
            count($migrations),
318 3
            $this->getCurrentNumber(),
319
            $toNumber
320
        ));
321
322 3
        $this->runMigrations($migrations, $this->getDirection($toNumber));
323
324 3
        $this->addInfo(sprintf('Done! %s migrations migrated!', count($migrations)));
325
326 3
        return true;
327
    }
328
329
    /**
330
     * @param AbstractMigration[] $migrations
331
     * @param string $direction
332
     */
333 3
    protected function runMigrations(array $migrations, string $direction): void
334
    {
335 3
        foreach ($migrations as $migration) {
336 3
            if ($direction === self::DIRECTION_UP) {
337 2
                $this->addInfo(sprintf(' - Migrating up %d...', $migration->getNumber()));
338 2
                $migration->up();
339 2
                $this->addMigratedMigration($migration);
340
341
            } else {
342 1
                $this->addInfo(sprintf(' - Migrating down %d...', $migration->getNumber()));
343 1
                $migration->down();
344 1
                $this->deleteMigratedMigration($migration);
345
346
            }
347
        }
348 3
    }
349
350 4
    protected function addInfo(string $info): void
351
    {
352 4
        if ($this->infoCallback) {
353 4
            $callback = $this->infoCallback;
354 4
            $callback($info);
355
        }
356 4
    }
357
}
358